Repository navigation
feat: verify cached SAM CLI archive integrity - #149
Conversation
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.
bnusunny
left a comment
There was a problem hiding this comment.
Reviewed at 9157adc. The approach is sound: the expected digest always comes from the release API, never from the cache, and nothing unverified is ever written to the cache. npm test is green (29 tests), and a fresh ncc build reproduces the committed dist/index.js exactly.
I also mutation-checked the guards. Skipping verification on cache hit or after download, forcing the hash comparison true, caching no-digest versions, reverting the key prefix, and downgrading the download-mismatch setFailed to a warning all turn tests red. Two suggested fixes and a few nits:
1. A tampered entry is not actually discarded (lib/setup.js:465)
Cache entries are immutable. On mismatch the code re-downloads and calls saveCache with the same key, which @actions/cache rejects with ReserveCacheError and logs only at info level. So the bad entry stays. Every run restores it, fails verification, downloads, and fails to save. Since it is read on every run, it never ages out either. This stays fail-safe, but the "Discarding the cache entry" warning is untrue and the slow path is permanent. Suggest rewording it to say the entry is being ignored, and pointing at gh cache delete <key> or the Actions cache UI to clear it.
2. Pinned versions now make an unauthenticated API call on every run
Before this change, a pinned version that hit the cache made no API call. Now every run fetches releases/tags/v{version}, and token has no default. Unauthenticated requests are limited to 60/hour per IP. When that runs out, getReleaseByTag returns null and the run falls back to an uncached download. That slowdown is separate from the pre-digest-version one described in the PR. Suggest defaulting the input the way the first-party setup actions do:
token:
default: ${{ github.server_url == 'https://github.com' && github.token || '' }}3. Nit: tests don't pin which asset's digest is selected
releaseBody() builds a release with a single asset. So changing the match to a.name.endsWith(".zip") still passes, and so does accepting a digest that isn't sha256:-shaped. Real releases carry both the x86_64 and arm64 zips. Putting both in the fixture, with different digests, would catch the wrong one being chosen. The arm64 CI job shows the code is correct today; this only protects it going forward.
4. Nit: ImageOS in the cache key (lib/setup.js:446)
This made sense when an extracted install was cached. The archive is the same file on every image, so the key now splits the cache for no benefit. Fine to leave.
5. Pre-existing, not introduced here
- Any exception in
downloadVerifyAndCache/downloadWithoutCachereturns""with only a warning, so the step succeeds with nosamonPATH. core.warning("...", error)atlib/setup.js:192passes the error as the annotation-properties argument, so its message is dropped.
Agree that v2 should be decided separately.
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.
|
Thanks for the thorough review — particularly the mutation checks, which caught two guards that had no real coverage. 1. Tampered entry is not discarded — fixed You're right, and the wording was the smaller half of it. Entries are immutable, so re-saving under the same key is rejected; and because the entry is restored on every run it never ages out either, so the mismatch recurs indefinitely with no self-healing path. The warning now says the cached copy is being ignored, states that the entry cannot be overwritten, and prints I did not make the action delete the entry itself: that needs A test now asserts the warning contains the 2. Unauthenticated API call on every run — fixed Agreed, and this one is a regression I introduced: before this change a pinned version that hit the cache made no API call at all. Adopted your suggested default verbatim, which matches Worth noting the failure mode this closes is distinct from the one in the PR description: that one is a version with no published digest, which is deterministic. This one is a version that has a digest we simply couldn't fetch, which is intermittent and depends on unrelated jobs sharing the runner IP — harder to diagnose. README updated, and I dropped the explicit 3. Fixture doesn't pin asset selection — fixed
Confirmed both gaps now close: matching assets by suffix fails 11 tests, and dropping the 4. Your reasoning is correct: the archive is byte-identical across images, so the key fragments the cache for no benefit, and two Ubuntu jobs on the same version will each store their own ~95 MB copy against the repo's cache quota. Leaving it anyway, because it doesn't affect correctness and changing the key again would mean re-validating the cache paths for what is purely an optimisation. Flagging it as worth a follow-up rather than folding it in here. 5. Pre-existing issues — agreed, separately Both are real and worth fixing, and I'd rather not fold them into this change so it stays reviewable as one thing:
Happy to take both in a follow-up PR.
|
bnusunny
left a comment
There was a problem hiding this comment.
Re-reviewed at 2102106. Thanks for the quick turnaround. npm test passes (35 tests), and a fresh ncc build matches the committed dist/index.js.
I re-ran the mutation checks. Both earlier survivors now die: matching assets by suffix fails 12 tests, and dropping the sha256: format check fails 4. The original guards still die too, and so does removing the gh cache delete hint from the warning.
Items 2–5 look good to me. That covers the token default (including not sending it off-instance on GHES), the fixture, leaving ImageOS for a follow-up, and splitting out the pre-existing issues.
One thing left on item 1: the save path still reports success when the save is rejected (lib/setup.js:326-336)
This one is partly on me. I wrote that @actions/cache "rejects" the re-save with ReserveCacheError, which reads as if it throws. It doesn't. In 4.1.0 (the installed version), saveCache catches ReserveCacheError itself, logs Failed to save: … at info level, and returns -1 (node_modules/@actions/cache/lib/cache.js:489-490,516). The same applies to every error except ValidationError.
So in exactly the tampered-entry case:
- the new comment and warning in the
catchblock never run; - the return value is ignored, and the next line logs
Cached AWS SAM CLI <version> to GitHub Actions cache.
The test for this case mocks saveCache with mockRejectedValue(...) (test/setup.test.js:400-406), which the real library never does. That is why the test passes.
Suggested fix:
const cacheId = await cache.saveCache([archivePath], cacheKey);
if (cacheId === -1) {
core.warning(`AWS SAM CLI ${version} was not saved to the GitHub Actions cache ...`);
} else {
core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`);
}Then change the mock to mockResolvedValue(-1). Keep the catch for ValidationError.
Nit: the test title at test/setup.test.js:241 still says "discarded".
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.
|
Fixed in 5c9eaa3. Save path now checks the return value. I logged Went one step further on the rejected-entry path: when a restored entry fails verification, the key is already known to be taken, so the save is now skipped entirely. Since Tests now mock
|
bnusunny
left a comment
There was a problem hiding this comment.
Re-reviewed at 5c9eaa3. The save path now keys off saveCache's return value, ValidationError is still caught, and the mocks match the library's real behaviour. npm test is green (37), dist matches a fresh ncc build, and mutation checks on the new logic (ignoring -1, saving after a rejected entry, never saving, rethrowing ValidationError) all fail a test. LGTM, thanks.
Summary
The native installer path cached the extracted install directory and added it to
PATHon a cache hit without checking it against anything. GitHub's own caching guidance notes that cache contents are not signed or verified, and that restored content should not be trusted implicitly. This change makes the action verify what it restores.What changed
Cache the release archive instead of the extracted directory. An extracted directory cannot be checked against a published digest; the archive can. The archive is extracted to a temporary directory after it passes verification.
Verify against the digest published on the GitHub release.
installUsingNativeInstallernow fetches the release metadata (for a pinned version viareleases/tags/v{version}, which it previously did not need) and reads thedigestfield of the relevant asset. On a cache hit the restored archive is hashed and compared; on mismatch the entry is discarded and the official archive is downloaded instead. Freshly downloaded archives are verified too, and a failure there fails the run rather than proceeding.Bypass the cache when there is nothing to verify against. GitHub only populates the asset
digestfield for releases published after roughly mid-2025. For older versions, and when the release metadata request fails, the action downloads the archive on every run and does not write to the cache. Caching only what can be verified keeps the behaviour fail-closed rather than falling back to unverified content.Cache key prefix is now
sam-cli-verified. Entries written under the previous layout hold an extracted directory rather than an archive, so they must not be restored by this version.New
cache-hitoutput. The cache path previously had no observable signal, so no test could assert whether it was taken. The output makes that assertable and gives consumers visibility into whether the install came from the cache.Behaviour change for consumers
Pinning a version published before the digest field existed (for example
1.104.0) now downloads on every run instead of using the cache. Functionally identical, slower on repeat runs. Versions from roughly1.140.0onward are unaffected and still benefit from caching. No input changes; nothing consumers need to update.Testing
Unit tests cover digest match, digest mismatch, absent digest, release metadata lookup failure, and post-download verification failure. They use real temporary files and real SHA256 computation rather than stubbing the hash.
Two integration cases were added to
test.yml. One pins a version with a published digest and runs the action twice, asserting the second run reportscache-hit == true— this is the only coverage of the verified cache-hit path against the real cache service, since the unit tests mock@actions/cache. The other asserts that a version with no published digest does not use the cache. Existing pinned versions intest.ymlwere left unchanged so the no-digest path keeps its coverage; the step previously named "should use cache" was renamed, since with this change that version no longer does.npm run allis green.Note for maintainers
release.ymlrolls only thev3tag, sov2will not pick this up. Worth deciding separately whetherv2should receive the same change or be formally wound down.