TT Lab
Get started
Learn Learning paths Courses

CI/CD Pipelines

One line changed in the lock file, and the cache brought the old dependency back

Continue in TT Lab

Goal

You build pipeline cache keys by hand to produce hits, misses and prefix fallbacks, confirm the difference between a cache and an artifact with a deletion experiment, reproduce what gets contaminated when you leave the runtime version and branch out of the key, and then even build a hit rate report and a trust gate.

Why it matters

A cache is the device that is easiest to add to a pipeline and stays wrong for the longest. If the key is too narrow, it misses every time and is the same as having no cache, and if it is too wide, it revives as they are the changed dependencies, a different runtime, or content made by someone else's branch. The latter failure is far worse, and because the build succeeds, nobody reports it. This is why "clearing the cache fixes it" is not a joke — that phrase means the cache has become a hidden input. So cache design is a matter of deciding three things. What must change the key when it changes, how much may be borrowed when there is no exact match, and who may write to the cache. Once you decide these three, whether the tool is GitHub Actions, GitLab CI or BuildKit, you only have to transcribe the same settings.

Steps

  1. Create the lab foundation under /root/cache-lab. (1) In /root/cache-lab/env/runtime.txt, the single line python3.12. (2) In /root/cache-lab/deps.lock, write six lines of <이름> <판> (name, version) in this order: left-pad 1.3.0, fast-json 2.1.4, tiny-http 0.9.2, color-util 4.0.1, date-fmt 3.2.2, crypto-lite 1.0.7. (3) /root/cache-lab/seed-repo.sh <작업디렉터리> (work directory) reads the lock file and, for each package, creates 12 files from <작업디렉터리>/pkgrepo/<이름>/<판>/f01.txt to f12.txt, and writes only one line <이름> <판> <번호두자리> (name, version, two-digit number) in each file. (4) /root/cache-lab/install.sh <작업디렉터리> reads the lock file, imitates the download cost with sleep 0.05 for each package, then copies pkgrepo/<이름>/<판> to vendor/<이름>, and finally writes, into vendor/_built-with.txt, the content of env/runtime.txt and prints one line installed=<개수> ms=<밀리초> (count, milliseconds) (vendor is created fresh each time). (5) After running the two scripts in turn, in /root/cache-lab/reports/cold.json, write three numbers: files, bytes and ms. files is find /root/cache-lab/vendor -type f | wc -l, bytes is the sum of the sizes of those files, and ms is the value install.sh reported.
  2. Build the skeleton of cache saving and restoring. /root/cache-lab/cache-save.sh <작업디렉터리> <열쇠> (work directory, key) bundles the vendor directory into <작업디렉터리>/cache/<열쇠>.tar.gz (tar czf ... -C <작업디렉터리> vendor), and in <작업디렉터리>/cache/index.txt appends one line <시각><탭><열쇠> (time, tab, key), and then prints saved <열쇠>. /root/cache-lab/cache-restore.sh <작업디렉터리> <열쇠>, if that tar exists, deletes vendor and unpacks it and ends with hit <열쇠> and exit code 0; if it does not exist, it does not touch vendor and ends with miss <열쇠> and exit code 1. Then save with the key manual-v1, delete the entire vendor, and restore with the same key. Write the fingerprints from before and after the restore in /root/cache-lab/reports/roundtrip.txt as three lines: before=<지문>, after=<지문> and same=yes (fingerprint). Calculate the fingerprint with cd /root/cache-lab/vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16.
  3. Create /root/cache-lab/cache-key.sh <작업디렉터리> (work directory). The output is one line in the form deps-<잠금해시12자> (12 characters of the lock hash). The lock hash is sha256sum < <작업디렉터리>/deps.lock | cut -c1-12. The key must not contain / (it becomes a file name), and it must end with the 12 characters of the lock hash — because in later steps you will attach more items at the front. Next create /root/cache-lab/cache-run.sh <작업디렉터리>. It builds the key and tries to restore, and if it hits, it ends there; if it misses, it runs install.sh and then saves with that key. And it appends to <작업디렉터리>/reports/runs.jsonl one line {"key":"<열쇠>","result":"hit|miss","ms":<밀리초>} (key, result, milliseconds), and prints <결과> <열쇠> <밀리초>ms on the screen. With vendor and the cache deleted, run cache-run.sh twice and confirm that the first misses and the second hits, then write four lines in /root/cache-lab/reports/keys.txt: run1=<첫 결과> (first result), run2=<두 번째 결과> (second result), lock-sha12=<지금 잠금 해시 12자> (the current lock hash, 12 characters), and the 12 characters of the lock hash assuming fast-json was changed from 2.1.4 to 2.2.0, as bumped-lock-sha12=<값> (value) (you do not change the lock file itself; you only calculate).
  4. To cache-restore.sh, add a third argument <접두> (prefix). If there is no exact key and a prefix is given, it unpacks, among the keys recorded in cache/index.txt, the most recently saved one that starts with that prefix and whose tar actually exists, and ends with partial <그열쇠> (that key) and exit code 2. If there is no prefix either or nothing matches, it is miss and 1 as before. cache-run.sh calculates the prefix as "${KEY%-*}-" and passes it on, and if the result is partial, it must run install.sh again and then save with the exact key (a result value of partial is added in runs.jsonl). Now you reproduce the danger. Take a copy with cp -a /root/cache-lab /root/cache-lab-bump, in the copy's deps.lock, change fast-json 2.1.4 to fast-json 2.2.0, and then fill the copy's repository with seed-repo.sh. Just before running cache-run.sh in the copy, trigger only the prefix fallback (since there is no exact key) and check at that time the version written in vendor/fast-json/f01.txt, and then run cache-run.sh to the end and confirm that the version gets corrected. Write the result in four lines in /root/cache-lab/reports/stale.txt: exact=miss, fallback=<접두 대체로 가져온 열쇠> (the key fetched by prefix fallback), before-install=fast-json <그때 판> (the version at that time), after-install=fast-json <끝난 뒤 판> (the version after it finished). You do not touch the lock file of the original /root/cache-lab.
  5. Create /root/cache-lab/build.sh <작업디렉터리> (work directory). Gather, for every *.txt under vendor, the sha256sum in path order and write it to <작업디렉터리>/dist/bundle.txt, and write the first 16 characters of that file's sha256 to <작업디렉터리>/dist/bundle.id (you can use ( cd vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum ) as it is). Do not mix in times or random numbers. In /root/cache-lab, run cache-run.sh and build.sh to produce the artifact, then take a copy with cp -a /root/cache-lab /root/cache-lab-nocache and delete the copy's cache directory and vendor and dist entirely. In the copy, run cache-run.sh and build.sh again. Write four lines in /root/cache-lab/reports/cache-drop.txt: with-cache=<원본의 bundle.id> (the original's bundle.id), without-cache=<사본의 bundle.id> (the copy's bundle.id), same=yes and artifact-in-cache=no. The last line is the result of opening every tar in /root/cache-lab/cache with tar tzf and confirming that not a single entry starts with dist/.
  6. First reproduce the accident. Take a copy with cp -a /root/cache-lab /root/cache-lab-py313, change the copy's env/runtime.txt to python3.13, and then run cache-run.sh with the current key (which does not know the runtime). The result will be a hit, and vendor/_built-with.txt will contain python3.12. Next fix /root/cache-lab/cache-key.sh to put the operating system, architecture and runtime version at the front of the key: <uname -s 소문자>-<uname -m>-<런타임을 영숫자 외에는 - 로 바꾼 값>-deps-<잠금해시12> (lowercase uname -s, uname -m, the runtime with everything other than alphanumerics replaced by -, then deps and the 12-character lock hash). (python3.12 becomes python3-12. The rule that the end is the 12 characters of the lock hash stays as it is.) Copy the fixed script into the copy as well, run cache-run.sh again in the copy, and confirm that this time it misses and is freshly installed with 3.13. Write six lines in /root/cache-lab/reports/toolver.txt: blind-key=, blind-result=, blind-built-with=, versioned-key=, versioned-result= and versioned-built-with=. The first three are the values from running with the key that does not know the runtime, and the last three are the values from running with the fixed key (the key, the result and the version recorded in vendor).
  7. Make /root/cache-lab a git repository (git init -b main, set the user name and email, put vendor/, cache/, dist/ and reports/ in .gitignore and commit). In /root/cache-lab/env/default-branch.txt, write the single line main. Create /root/cache-lab/cache-policy.sh <작업디렉터리> <브랜치> (work directory, branch). If the branch equals the value of default-branch.txt, it is save and exit code 0; if different, nosave and 2; and if the default branch cannot be read, a line starting with unknown and 1 (do not hard-code the branch name in the script). cache-key.sh adds the branch at the very front of the key: <브랜치를 영숫자 외에는 - 로 바꾼 값>-<uname -s 소문자>-<uname -m>-<런타임>-deps-<잠금해시12> (the branch with everything other than alphanumerics replaced by -, then lowercase uname -s, uname -m, the runtime, deps and the 12-character lock hash). If you give a branch as the second argument, it calculates that branch's key. cache-run.sh reads broadly (my branch's key → my branch's prefix → the default branch's key → the default branch's prefix) and writes narrowly (only when cache-policy.sh says save). Add "saved":"yes|no" to the runs.jsonl line. You check in a copy. cp -a /root/cache-lab /root/cache-lab-feature, inside it git checkout -b feature/spike, to the end of deps.lock, add md5-lite 0.4.0, fill the repository with seed-repo.sh, and then run cache-run.sh. Write five lines in /root/cache-lab/reports/branch.txt: branch=feature/spike, key=<사본의 열쇠> (the copy's key), result=<그 실행의 결과> (that run's result), policy=nosave and feature-tarballs=<사본의 cache 에 생긴 feature- 로 시작하는 tar 개수> (the number of tars starting with feature- in the copy's cache).
  8. Create /root/cache-lab/cache-report.sh <작업디렉터리> (work directory). It reads <작업디렉터리>/reports/runs.jsonl and reports/cold.json, writes <작업디렉터리>/reports/summary.json and also prints it on the screen. There are six fields: runs (the number of lines), hits, partial and misses (the number of lines per result), hit_rate (hits × 100 ÷ runs rounded down to an integer), and saved_ms (for each line whose result is hit, the value from cold.json's ms minus that line's ms, summed, with negatives as 0). If there are no lines at all, hit_rate is 0. And create /root/cache-lab/cache-trust.sh <작업디렉터리>. If any of deps.lock, vendor and env/runtime.txt is missing, it is a line starting with ERROR and exit code 1; if vendor/_built-with.txt is missing or differs from env/runtime.txt or the version of any package in the lock file differs from the version in vendor/<이름>/f01.txt, it is a line starting with REBUILD and 2; and if everything matches, TRUST and 0. Finally, in /root/cache-lab, run the two scripts and leave /root/cache-lab/reports/summary.json.

Notes

How much does it cost to set up once without a cache

Create the lab foundation under /root/cache-lab. (1) In /root/cache-lab/env/runtime.txt, the single line python3.12. (2) In /root/cache-lab/deps.lock, write six lines of <이름> <판> (name, version) in this order: left-pad 1.3.0, fast-json 2.1.4, tiny-http 0.9.2, color-util 4.0.1, date-fmt 3.2.2, crypto-lite 1.0.7. (3) /root/cache-lab/seed-repo.sh <작업디렉터리> (work directory) reads the lock file and, for each package, creates 12 files from <작업디렉터리>/pkgrepo/<이름>/<판>/f01.txt to f12.txt, and writes only one line <이름> <판> <번호두자리> (name, version, two-digit number) in each file. (4) /root/cache-lab/install.sh <작업디렉터리> reads the lock file, imitates the download cost with sleep 0.05 for each package, then copies pkgrepo/<이름>/<판> to vendor/<이름>, and finally writes, into vendor/_built-with.txt, the content of env/runtime.txt and prints one line installed=<개수> ms=<밀리초> (count, milliseconds) (vendor is created fresh each time). (5) After running the two scripts in turn, in /root/cache-lab/reports/cold.json, write three numbers: files, bytes and ms. files is find /root/cache-lab/vendor -type f | wc -l, bytes is the sum of the sizes of those files, and ms is the value install.sh reported.

There is no internet, so you cannot do a real download — the repository is also a directory we made, and the cost is only imitated with sleep. Even so, the values you measure (file counts, bytes, milliseconds) are real. Make both scripts take the work directory as their first argument. In later steps you take a copy of this lab directory and run the same scripts. seq -w 1 12 counts from 01 to 12 in two digits. You can add up the file size sum with find ... -printf '%s\n' and awk (this Pod has no bc).

Try saving, deleting and reviving

Build the skeleton of cache saving and restoring. /root/cache-lab/cache-save.sh <작업디렉터리> <열쇠> (work directory, key) bundles the vendor directory into <작업디렉터리>/cache/<열쇠>.tar.gz (tar czf ... -C <작업디렉터리> vendor), and in <작업디렉터리>/cache/index.txt appends one line <시각><탭><열쇠> (time, tab, key), and then prints saved <열쇠>. /root/cache-lab/cache-restore.sh <작업디렉터리> <열쇠>, if that tar exists, deletes vendor and unpacks it and ends with hit <열쇠> and exit code 0; if it does not exist, it does not touch vendor and ends with miss <열쇠> and exit code 1. Then save with the key manual-v1, delete the entire vendor, and restore with the same key. Write the fingerprints from before and after the restore in /root/cache-lab/reports/roundtrip.txt as three lines: before=<지문>, after=<지문> and same=yes (fingerprint). Calculate the fingerprint with cd /root/cache-lab/vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16.

It is better to handle the cache as one tar chunk — it is faster than moving thousands of files one by one, and permissions and empty directories are preserved together. When the restore misses, you must not delete vendor. A miss is not an error but a normal result, and what you do then is install, not destroy. Make the exit codes distinguish hit from miss. In a later step you put a third case on top of these two codes. If you turn on set -e, the script dies first at a miss.

Build the key from the lock file hash

Create /root/cache-lab/cache-key.sh <작업디렉터리> (work directory). The output is one line in the form deps-<잠금해시12자> (12 characters of the lock hash). The lock hash is sha256sum < <작업디렉터리>/deps.lock | cut -c1-12. The key must not contain / (it becomes a file name), and it must end with the 12 characters of the lock hash — because in later steps you will attach more items at the front. Next create /root/cache-lab/cache-run.sh <작업디렉터리>. It builds the key and tries to restore, and if it hits, it ends there; if it misses, it runs install.sh and then saves with that key. And it appends to <작업디렉터리>/reports/runs.jsonl one line {"key":"<열쇠>","result":"hit|miss","ms":<밀리초>} (key, result, milliseconds), and prints <결과> <열쇠> <밀리초>ms on the screen. With vendor and the cache deleted, run cache-run.sh twice and confirm that the first misses and the second hits, then write four lines in /root/cache-lab/reports/keys.txt: run1=<첫 결과> (first result), run2=<두 번째 결과> (second result), lock-sha12=<지금 잠금 해시 12자> (the current lock hash, 12 characters), and the 12 characters of the lock hash assuming fast-json was changed from 2.1.4 to 2.2.0, as bumped-lock-sha12=<값> (value) (you do not change the lock file itself; you only calculate).

A key is a one-string summary of "what this cache contains". The lock file is a list pinned down to the version, so it suits that summary — conversely, if you hash a file such as package.json, in which ranges (^1.2) are written, what is actually installed differs even when the content is the same. If you put an unchanged file into the hash, the key changes too often for nothing, and if you leave out a file that does change, a stale cache keeps hitting. The second value can be calculated without changing the file, like sed 's/fast-json 2.1.4/fast-json 2.2.0/' deps.lock | sha256sum.

If there is no exact key, bring the closest one

To cache-restore.sh, add a third argument <접두> (prefix). If there is no exact key and a prefix is given, it unpacks, among the keys recorded in cache/index.txt, the most recently saved one that starts with that prefix and whose tar actually exists, and ends with partial <그열쇠> (that key) and exit code 2. If there is no prefix either or nothing matches, it is miss and 1 as before. cache-run.sh calculates the prefix as "${KEY%-*}-" and passes it on, and if the result is partial, it must run install.sh again and then save with the exact key (a result value of partial is added in runs.jsonl). Now you reproduce the danger. Take a copy with cp -a /root/cache-lab /root/cache-lab-bump, in the copy's deps.lock, change fast-json 2.1.4 to fast-json 2.2.0, and then fill the copy's repository with seed-repo.sh. Just before running cache-run.sh in the copy, trigger only the prefix fallback (since there is no exact key) and check at that time the version written in vendor/fast-json/f01.txt, and then run cache-run.sh to the end and confirm that the version gets corrected. Write the result in four lines in /root/cache-lab/reports/stale.txt: exact=miss, fallback=<접두 대체로 가져온 열쇠> (the key fetched by prefix fallback), before-install=fast-json <그때 판> (the version at that time), after-install=fast-json <끝난 뒤 판> (the version after it finished). You do not touch the lock file of the original /root/cache-lab.

This is what GitHub Actions' restore-keys and GitLab's fallback_keys do. If the key has the form <접두>-<해시>, you can keep only the prefix and find the closest cache — that is why the key had to end with the hash. A partial restore looks like a free gain, but at that moment vendor holds a version that the lock file does not require. If you skip the installation here, that version gets mixed into the build as it is, and the result differs on the day the cache disappears. The reason to work in a copy is to protect the original's lock file. Making the scripts take the work directory as the first argument pays off here.

Even if you delete the cache entirely, the result must be the same

Create /root/cache-lab/build.sh <작업디렉터리> (work directory). Gather, for every *.txt under vendor, the sha256sum in path order and write it to <작업디렉터리>/dist/bundle.txt, and write the first 16 characters of that file's sha256 to <작업디렉터리>/dist/bundle.id (you can use ( cd vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum ) as it is). Do not mix in times or random numbers. In /root/cache-lab, run cache-run.sh and build.sh to produce the artifact, then take a copy with cp -a /root/cache-lab /root/cache-lab-nocache and delete the copy's cache directory and vendor and dist entirely. In the copy, run cache-run.sh and build.sh again. Write four lines in /root/cache-lab/reports/cache-drop.txt: with-cache=<원본의 bundle.id> (the original's bundle.id), without-cache=<사본의 bundle.id> (the copy's bundle.id), same=yes and artifact-in-cache=no. The last line is the result of opening every tar in /root/cache-lab/cache with tar tzf and confirming that not a single entry starts with dist/.

A cache and an artifact are separated by what happens when you delete them. A cache must be re-creatable even if it is gone, and if the result differs, it is not a cache but a hidden input. An artifact is something that cannot be recreated once gone (the build machine has disappeared) or must not be recreated (the very file already deployed). That is why you must not put an artifact into the cache — a cache is normally expired and emptied, but if deployed files are in it, expiry becomes an accident. The build must be deterministic for this comparison to hold. If even one line of timestamp gets mixed in, the two results always differ.

The runtime version was raised but a cache installed with the old version came along

First reproduce the accident. Take a copy with cp -a /root/cache-lab /root/cache-lab-py313, change the copy's env/runtime.txt to python3.13, and then run cache-run.sh with the current key (which does not know the runtime). The result will be a hit, and vendor/_built-with.txt will contain python3.12. Next fix /root/cache-lab/cache-key.sh to put the operating system, architecture and runtime version at the front of the key: <uname -s 소문자>-<uname -m>-<런타임을 영숫자 외에는 - 로 바꾼 값>-deps-<잠금해시12> (lowercase uname -s, uname -m, the runtime with everything other than alphanumerics replaced by -, then deps and the 12-character lock hash). (python3.12 becomes python3-12. The rule that the end is the 12 characters of the lock hash stays as it is.) Copy the fixed script into the copy as well, run cache-run.sh again in the copy, and confirm that this time it misses and is freshly installed with 3.13. Write six lines in /root/cache-lab/reports/toolver.txt: blind-key=, blind-result=, blind-built-with=, versioned-key=, versioned-result= and versioned-built-with=. The first three are the values from running with the key that does not know the runtime, and the last three are the values from running with the fixed key (the key, the result and the version recorded in vendor).

A cache key must summarize "every input that produced this cache". If you put in only the dependency list, then even with the same list, binaries built on a different runtime or a different distribution come along as they are — and since the installation succeeds, nobody notices. That is why in any CI documentation the example key starts like ${{ runner.os }}-node-.... A person must be able to read the key and know "where this cache was made". To replace non-alphanumeric characters, tr -c 'A-Za-z0-9' '-' is convenient. Trim the leftover tail with sed 's/-*$//'.

A short-lived branch stains everyone's cache

Make /root/cache-lab a git repository (git init -b main, set the user name and email, put vendor/, cache/, dist/ and reports/ in .gitignore and commit). In /root/cache-lab/env/default-branch.txt, write the single line main. Create /root/cache-lab/cache-policy.sh <작업디렉터리> <브랜치> (work directory, branch). If the branch equals the value of default-branch.txt, it is save and exit code 0; if different, nosave and 2; and if the default branch cannot be read, a line starting with unknown and 1 (do not hard-code the branch name in the script). cache-key.sh adds the branch at the very front of the key: <브랜치를 영숫자 외에는 - 로 바꾼 값>-<uname -s 소문자>-<uname -m>-<런타임>-deps-<잠금해시12> (the branch with everything other than alphanumerics replaced by -, then lowercase uname -s, uname -m, the runtime, deps and the 12-character lock hash). If you give a branch as the second argument, it calculates that branch's key. cache-run.sh reads broadly (my branch's key → my branch's prefix → the default branch's key → the default branch's prefix) and writes narrowly (only when cache-policy.sh says save). Add "saved":"yes|no" to the runs.jsonl line. You check in a copy. cp -a /root/cache-lab /root/cache-lab-feature, inside it git checkout -b feature/spike, to the end of deps.lock, add md5-lite 0.4.0, fill the repository with seed-repo.sh, and then run cache-run.sh. Write five lines in /root/cache-lab/reports/branch.txt: branch=feature/spike, key=<사본의 열쇠> (the copy's key), result=<그 실행의 결과> (that run's result), policy=nosave and feature-tarballs=<사본의 cache 에 생긴 feature- 로 시작하는 tar 개수> (the number of tars starting with feature- in the copy's cache).

If you let any branch save to the cache, the dependencies left behind by a branch you experimented on and deleted seep into default-branch builds. There is also no way to undo it — because that branch is already gone. So the rule in practice is "anyone may read, only the default branch may write". Branch names often contain /, but the key becomes a file name. If you do not replace it, one more directory is created like cache/feature/spike-...tar.gz, or the save itself fails. git -C <경로> rev-parse --abbrev-ref HEAD tells you the current branch. It may not be a git repository, so include a fallback for when it fails.

Turn "can I trust this cache" into a gate

Create /root/cache-lab/cache-report.sh <작업디렉터리> (work directory). It reads <작업디렉터리>/reports/runs.jsonl and reports/cold.json, writes <작업디렉터리>/reports/summary.json and also prints it on the screen. There are six fields: runs (the number of lines), hits, partial and misses (the number of lines per result), hit_rate (hits × 100 ÷ runs rounded down to an integer), and saved_ms (for each line whose result is hit, the value from cold.json's ms minus that line's ms, summed, with negatives as 0). If there are no lines at all, hit_rate is 0. And create /root/cache-lab/cache-trust.sh <작업디렉터리>. If any of deps.lock, vendor and env/runtime.txt is missing, it is a line starting with ERROR and exit code 1; if vendor/_built-with.txt is missing or differs from env/runtime.txt or the version of any package in the lock file differs from the version in vendor/<이름>/f01.txt, it is a line starting with REBUILD and 2; and if everything matches, TRUST and 0. Finally, in /root/cache-lab, run the two scripts and leave /root/cache-lab/reports/summary.json.

The hit rate alone decides nothing. A cache that hits 100% can revive 100% wrong content — that is what you saw in earlier steps. So you put "can I trust it" next to the report separately. The grounds for the gate's verdict must all be the state that is on the disk right now. Look not at what the last run wrote down but at what is in vendor right now. jq -s reads multi-line JSON as a single array. To make the division result an integer, use | floor. Using add on an empty array gives null, so guard against it with + [0].