Linux & Bash · Part II · Module 32 · Regression Automation End to End
Nightly Runs, Trends and Archiving
A regression that runs unattended every night raises two problems the interactive one never had: the environment it inherits is not yours, and ninety nights of output will not fit. This chapter measures a nightly run that lost its tools and its image digest while every stage reported success, builds a trend that refuses to compare incomparable nights, and quantifies what to throw away.
Advanced26 min readLinuxPart IIRegressionSchedulingArchiving
Part II · Module 32 · Chapter 32.8 · Nightly Runs, Trends and Archiving
Chapter 32.7 finished the regression as a thing you invoke. Running it unattended, every night, for a year, adds exactly two new problems — the environment is not the one you tested in, and the output does not fit — and one new capability, which is that a year of results is a measurement you cannot otherwise make.
1. The Night It Ran With No Tools
A scheduled job does not inherit your shell. Whatever runs it — cron, a CI agent, a batch system — hands the job a minimal environment, and the cheapest way to find out what that does to a regression is to reproduce it:
$ echo $PATH | tr ':' '\n' | grep -c .
15
$ env -i /bin/bash -lc 'echo $PATH | tr ":" "\n" | grep -c .'
11
$ env -i /bin/bash -c 'echo "$PATH"'
/usr/gnu/bin:/usr/local/bin:/bin:/usr/bin:.Fifteen PATH entries interactively, eleven in a login shell, and five with no environment at all. Chapter 30.5 measured the same gap for a submitted job and made the same point: env -i is a free, local, exact reproduction of the thing that breaks scheduled runs.
Running the driver that way:
$ env -i HOME="$HOME" /bin/bash -c "cd $PWD && SLOTS=2 LIMIT_S=3 bin/regress"
bin/regress: line 22: md5sum: command not found
regress: /…/regr/20261009T100443Z
regress: 24 runs, 2 slots, 3s limit each
regress: dispatch finished in 2s (xargs rc=0)
regress: 24 result rowsTwo seconds for twenty-four simulations, which is six times faster than it has any right to be. The report says why:
24 runs: 0 PASS 0 FAIL 0 TIMEOUT 24 CRASH
0 NO_VERDICT 0 INCONSISTENT 0 VACUOUS
0 MISSING 0 NOSTATUS$ cat regr/20261009T100443Z/runs/b2b_s1/run.log
/…/bin/runsim: line 12: exec: vvp: not foundThe simulator is not on a minimal PATH. Twenty-four crashes, and the system handled it well: Chapter 32.3's classifier called every one of them CRASH rather than FAIL, Chapter 32.5's report put all twenty-four in the environment column, and the exit status was 1. Nothing lied.
Except for one line at the very top, which went to standard error, and which cron would have mailed to an address nobody reads.
2. The Line That Did Lie
bin/regress: line 22: md5sum: command not foundLine 22 was the image digest — Chapter 32.2's env.txt entry and the thing Chapter 32.6 called "the single highest-value thing a regression records about itself":
echo "image_md5=$(md5 -q "$root/logs/sim.vvp" 2>/dev/null || md5sum "$root/logs/sim.vvp" | cut -d' ' -f1)"That line is a fallback chain and it has no floor. Here is why both halves failed:
$ command -v md5
/sbin/md5
$ env -i /bin/bash -c 'command -v md5 || echo "md5 NOT on this PATH"'
md5 NOT on this PATH
$ env -i /bin/bash -c 'command -v md5sum || echo "md5sum NOT on this PATH"'
md5sum NOT on this PATHBoth of them live in /sbin, and /sbin is not on that PATH. The fallback chain reads as "try the BSD name, then the GNU name", which would be sound if the two names were in different places — and they are not:
$ ls -li /sbin/md5 /sbin/md5sum
1152921500312571498 -rwxr-xr-x 6 root wheel 136576 /sbin/md5
1152921500312571498 -rwxr-xr-x 6 root wheel 136576 /sbin/md5sum
$ env -i /bin/bash -c 'echo "PATH=[$PATH]"'
PATH=[/usr/gnu/bin:/usr/local/bin:/bin:/usr/bin:.]The same inode, under two names, six hardlinks to one multi-call binary — so md5sum does exist here (md5sum (Darwin) 1.0, and it even prints GNU-style output with the trailing -). The fallback was never a second platform; it was the same file in the same unreachable directory. So the first command fails silently (its stderr was redirected), the second fails loudly, and the substitution yields the empty string:
$ grep '^image_md5=' regr/20261009T100443Z/env.txt
image_md5=
$ grep '^image_md5=' regr/20261009T100443Z/env.txt | sed 's/^image_md5=//' | wc -c
0An empty digest. Not absent — present and empty, which is worse, because the field is there and looks filled in.
What a nightly job has to do about its environment
So the driver cannot assume anything it has not asserted:
# set up the environment explicitly, then CHECK it -- do not hope
. /etc/profile.d/eda-tools.sh 2>/dev/null || true
for t in iverilog vvp; do
command -v "$t" >/dev/null || { echo "regress: $t not on PATH -- refusing" >&2; exit 3; }
done
digest() { … } # absolute paths, with a refusalThree obligations, each matching something measured above. Source the setup rather than inheriting it — Chapter 25.6's subject. Assert the tools are reachable, and exit 3 (setup) rather than producing twenty-four crashes. And never depend on a tool's location being on PATH when you can name it.
Exiting 3 is the right value here precisely because of Chapter 32.7's division by who acts: a missing simulator is not a design question, and twenty-four CRASH rows invite somebody to go looking at twenty-four testbenches.
2b. Scheduling It
What actually exists on this machine:
crontab /usr/bin/crontab
cron /usr/sbin/cron
at /usr/bin/at
launchctl /bin/launchctl
systemctl ABSENT
anacron ABSENTcron is present and has no entries here (crontab: no crontab for …), and the shape of an entry is worth getting right once:
# m h dom mon dow command
30 22 * * 1-5 /proj/chip_a/bin/nightly >> /proj/chip_a/logs/nightly.out 2>&1Four things about that line, all of which follow from Sections 1 and 2 rather than from cron itself.
It calls a wrapper, not the driver. nightly is where the environment setup, the tool assertions and the retention policy live. Putting them in crontab makes them invisible and untestable.
It redirects both streams to a file. cron mails whatever a job writes, which means the md5sum: command not found line above goes to a mailbox rather than a log — and on a machine with no mail configured, nowhere at all. Append to a file somebody can read.
It appends rather than truncates, so last night's output is still there when tonight's job fails in the first second.
And >> file 2>&1, in that order. The reverse — 2>&1 >> file — points stderr at the terminal's stdout before stdout is redirected, so the diagnostics do not reach the file. Chapter 12.3 measured the ordering; this is the one line where getting it backwards loses exactly the message that explains the failure.
The one cron-specific thing worth knowing is that % is special in a crontab command and needs escaping as \%, which matters the moment a date format enters the line — and it is a reason to keep the command a bare wrapper invocation.
3. The Trend, and When Not to Draw One
Ninety nights of results.tsv is a dataset. Three of them, on this fixture:
REGRESSION runs PASS FAIL TIMEO CRASH NO_VE INCON VACUO MISSI NOSTA
20261009T093929Z 24 9 3 2 2 2 4 2 0 0
20261009T094027Z 24 13 1 2 2 2 2 2 0 0
20261009T094111Z 24 10 2 2 2 2 4 2 0 0Nine passes, then thirteen, then ten. Read as a trend: progress, then a regression on the third night. And the per-spec comparison between consecutive nights says otherwise:
verdict changes between consecutive regressions:
20261009T093929Z -> 20261009T094027Z
inconsistent:1 INCONSISTENT -> PASS
inconsistent:2 INCONSISTENT -> PASS
prio0:1 FAIL -> PASS
prio0:2 FAIL -> PASS
20261009T094027Z -> 20261009T094111Z
inconsistent:1 PASS -> INCONSISTENT
prio0:1 PASS -> FAIL
MATRIX CHANGED: 12 spec(s) gone, 12 new -- counts above are not comparableThe second transition shows two changed specs, not four, and then the line that matters: twelve specs left the matrix and twelve arrived, because the seed list changed. Chapter 32.5 measured that in detail; here it appears as an annotation on a trend table, which is the only place it can do any good.
A trend table without that annotation is actively misleading, because a chart is more persuasive than a report and nobody checks a chart's denominators. So the rule is: print the matrix change next to the counts, and say in words that the counts are not comparable. A trend over a changing matrix is not a trend; it is two different measurements plotted on one axis.
4. What a Regression Costs to Keep
$ find regr/20261009T093929Z -type f | wc -l
151
$ find regr/20261009T093929Z -type f -print0 | xargs -0 wc -c | tail -1
8585 total
$ du -sk regr/20261009T093929Z
604 regr/20261009T093929Z8,585 bytes of content in 618,496 bytes of disk — a factor of 72. One hundred and fifty-one files, most of them a handful of bytes, each occupying a whole filesystem block. That is what Chapter 28.3 measured in general; here it is the dominant cost of keeping history.
And what the obvious remedies are worth:
$ tar -cf /tmp/r.tar -C regr 20261009T093929Z
$ tar -czf /tmp/r.tgz -C regr 20261009T093929Z
tar : 534016 bytes
tar.gz : 14469 bytes (2.3% of the on-disk size)tar alone barely helps — it pads every file to a 512-byte record, so 151 tiny files are still 151 records plus headers. tar.gz is 2.3% of the directory's footprint, a factor of 42, because the content is highly repetitive text.
Now the instinct everybody has, which is to throw away the passing runs' logs:
$ # remove run.log from the 9 PASS runs
$ awk -F'\t' '$4=="PASS" {print $2"_s"$3}' results.tsv | while read -r d; do rm -f "runs/$d/run.log"; done
logs removed : 9
content now : 7343 bytes (was 8585)
tar.gz now : 13563 bytes (was 14469)A 6% saving, for the permanent loss of the evidence that nine runs passed.
5. Archiving That Can Be Trusted
#!/bin/bash
# archive REGRDIR -- turn a finished regression into one compressed file, and
# keep the summary readable without unpacking it.
# Measured on this fixture: the directory costs 72x its own content in disk
# blocks, and gzip gets the whole thing to 2.3% of that. Deleting the passing
# runs' logs first saves 6% -- so compress, do not prune.
set -u
d=${1:-}
[ -n "$d" ] || { echo "archive: need a regression directory" >&2; exit 2; }
[ -d "$d" ] || { echo "archive: '$d' is not a directory" >&2; exit 2; }
[ -r "$d/results.tsv" ] || { echo "archive: '$d' has no results.tsv -- refusing to archive an unfinished run" >&2; exit 2; }
base=$(cd "$(dirname "$d")" && pwd); name=$(basename "$d")
tgz=$base/$name.tar.gz
[ -e "$tgz" ] && { echo "archive: '$tgz' already exists" >&2; exit 2; }
# Keep the two small files OUTSIDE the archive, so a trend or a search never
# has to unpack anything.
cp "$d/results.tsv" "$base/$name.results.tsv"
cp "$d/env.txt" "$base/$name.env.txt" 2>/dev/null
before=$(( $(du -sk "$d" | cut -f1) * 1024 ))
tar -czf "$tgz" -C "$base" "$name" || { echo "archive: tar failed" >&2; rm -f "$tgz"; exit 2; }
# Verify the archive before removing anything. An archive nobody opened is a
# claim, not a backup.
n_in=$(find "$d" -type f | grep -c .)
n_ar=$(tar -tzf "$tgz" | grep -vc '/$')
if [ "$n_in" -ne "$n_ar" ]; then
echo "archive: $n_in files in, $n_ar in the archive -- NOT deleting the directory" >&2
exit 1
fi
after=$(wc -c < "$tgz" | tr -d ' ')
printf 'archive: %s -> %s\n' "$name" "$(basename "$tgz")"
printf ' %s files, %s bytes on disk -> %s bytes (%s%%)\n' \
"$n_in" "$before" "$after" "$(echo "scale=1; 100*$after/$before" | bc)"
printf ' summary kept unpacked: %s.results.tsv\n' "$name"
rm -rf "$d"One run of it:
$ archive regr/20261009T093929Z
archive: 20261009T093929Z -> 20261009T093929Z.tar.gz
151 files, 618496 bytes on disk -> 14469 bytes (2.3%)
summary kept unpacked: 20261009T093929Z.results.tsvThe part that matters is the four lines before the rm -rf:
n_in=$(find "$d" -type f | grep -c .)
n_ar=$(tar -tzf "$tgz" | grep -vc '/$')
if [ "$n_in" -ne "$n_ar" ]; then
echo "archive: $n_in files in, $n_ar in the archive -- NOT deleting the directory" >&2
exit 1
fiThe archive is read back and counted before anything is deleted. An archive nobody has opened is a claim, not a backup — and this is the one script in the module whose bug is unrecoverable, so it gets the only verification in the module that runs every time rather than only in a test.
Proven able to refuse, by making tar skip the logs:
$ sed 's|tar -czf "$tgz" -C "$base" "$name"|tar -czf "$tgz" --exclude "*.log" -C "$base" "$name"|' archive > /tmp/amut.sh
$ bash /tmp/amut.sh /tmp/at
archive: 151 files in, 127 in the archive -- NOT deleting the directory
mutant exit status: 1
directory still present: YES - refused to deleteOne hundred and fifty-one in, one hundred and twenty-seven archived, and the directory survived. And the other refusal:
$ archive /tmp/at2 # a regression with no results.tsv
archive: '/tmp/at2' has no results.tsv -- refusing to archive an unfinished run
rc=2An unfinished regression is not archived at all, because archiving is how a regression stops being re-runnable, and a run that never produced results is exactly the one somebody will want to look at.
Three smaller decisions in the same script:
It refuses to overwrite. [ -e "$tgz" ] && exit 2. An archive silently replacing another night's archive is the only way this script could destroy data twice.
It copies the summary out before archiving, not after, so a failure part-way through leaves the summary present rather than an archive with no index.
And tar -tzf | grep -vc '/$' counts entries that are not directories, because find -type f does not count directories either. Comparing a file count against an entry count would fail every time, and a verification that always fails gets switched off — which is the same end state as no verification, reached by a more annoying route.
6. The Module, Assembled
#!/bin/bash
# nightly -- what cron actually calls.
set -u
root=$(cd "$(dirname "$0")/.." && pwd)
cd "$root" || exit 3
# 1. the environment, asserted rather than inherited (32.8 §1)
. /etc/profile.d/eda-tools.sh 2>/dev/null || true
for t in iverilog vvp; do
command -v "$t" >/dev/null || { echo "nightly: $t not on PATH" >&2; exit 3; }
done
# 2. build once, and let the digest identify it (32.2, 32.6)
iverilog -o logs/sim.vvp -f cfg/files.f || exit 3
# 3. the regression (32.2 - 32.5)
set -o pipefail
bin/regress | tee -a logs/nightly.out
rc=$?
# 4. a stable path for whatever reads the result (32.7)
ln -sfn "$(ls -dt regr/2026* | head -1)" regr/latest
# 5. keep the history affordable (32.8 §4, §5)
for d in $(ls -dt regr/2026*/ 2>/dev/null | tail -n +8); do
bin/archive "${d%/}" || echo "nightly: archive failed for $d" >&2
done
find regr -name '*.tar.gz' -mtime +90 -print -delete
exit "$rc"Five stages again, at a different scale, and every line traceable to something measured:
| line | why | measured in |
|---|---|---|
| assert the tools | 24 CRASH rows from a missing PATH | §1 |
| the digest helper | an empty digest "reproduced" 24 crashes | §2 |
set -o pipefail | | tee returns tee's status | 32.7 |
tee -a | tonight must not erase last night | §2b |
ln -sfn | without -n the link lands inside the target | 32.7 |
| archive the 8th-newest onward | 72× disk overhead, 2.3% compressed | §4 |
| delete whole archives on age | half a regression answers nothing | §4 |
One thing to be explicit about: -mtime +90 skips the file that is exactly 90 days old, because find's -mtime +n means strictly greater than n days. Chapter 28.4 measured that off-by-one. It does not matter for a 90-day policy; it matters a great deal for a 7-day one, and it is the kind of thing that is only ever discovered when the file you wanted is gone.
Practice
- Run your driver under
env -iand read the first line of output. - Count your
PATHentries interactively, in a login shell, and underenv -i. - Find a tool your flow needs that is not on a minimal
PATH. - Find a fallback chain in your scripts where both branches can fail, and work out what the result is.
- Produce an empty digest and then compare two of them.
- Write a
digestfunction with absolute candidate paths and an explicit refusal. - Prove it refuses by breaking every candidate path.
- Prove it works under
env -i. - Assert your tools exist before running anything, and return the setup status.
- Write the crontab line calling a wrapper, with
>> file 2>&1in that order. - Reverse the redirection order and find out where the diagnostics went.
- Truncate instead of appending and then fail in the first second.
- Build a trend over three or more nights and read the counts.
- Change your seed list between two nights and confirm the trend says the counts are not comparable.
- Remove that annotation and show the trend to somebody; see what they conclude.
- Archive a night, then draw the trend across it without unpacking anything.
- Make the resolver return failure for a night with no results, and print the row anyway.
- Measure your own files, content and disk usage, and compute the ratio.
- Compare
taragainsttar.gzand explain the difference. - Delete the passing runs' logs and measure what you saved.
- Decide your retention policy from those two numbers, and write it down.
- Verify an archive by counting entries before deleting the directory.
- Break the archiving so it misses files and confirm the directory survives.
- Try to archive an unfinished regression and confirm it refuses.
- Archive over an existing archive and confirm it refuses.
- Check whether your retention
findincludes the boundary day.
Check Your Understanding
- What two new problems does running nightly introduce?
- How many
PATHentries didenv -igive, against interactive? - What happened to the 24 runs, and how did the system handle it?
- Which single line of that output was misleading, and where did it go?
- Why did both halves of the digest fallback fail?
- Why is an empty digest worse than an absent one?
- What did
rerundo when both digests were empty? - Why is
b2b:1 CRASH → CRASH reproducedthe most alarming line in that output? - State the general rule about fallback chains.
- What three things must a nightly driver do about its environment?
- Why exit 3 for a missing simulator rather than letting the runs crash?
- Why does the crontab line call a wrapper?
- Why
>> file 2>&1and not2>&1 >> file? - Why append rather than truncate?
- What is special about
%in a crontab command? - The pass counts went 9, 13, 10. What does the per-spec comparison add?
- Why is a trend table more dangerous than a report?
- What makes a trend across archived nights possible?
- Why does the resolver fail rather than guess, and what does the caller print?
- 151 files, 8,585 bytes of content — how much disk, and why?
- Why does
taralone barely help? - What does
tar.gzachieve, and why? - What does deleting the passing runs' logs save, and why so little?
- State the retention policy in three rules.
- What is the one artifact worth pruning, and what is the general rule?
- Why is the archive read back before the directory is deleted?
- How was that verification proven able to refuse?
- Why refuse to archive an unfinished regression?
- Why compare entry counts rather than file counts, and what happens if you get it wrong?
- What does
-mtime +90skip, and when does that matter?
7. Remember
- A scheduled job gets five
PATHentries where you had fifteen.env -ireproduces that for free, locally, exactly. - The simulator was not on a cron
PATH— 24 runs, 24CRASH, and the classifier, report and status all handled it correctly. md5is in/sbinandmd5sumdoes not exist here, so an unguarded fallback produced an empty digest, reported only on stderr.- Two empty digests compare equal, so the reproduction guard passed and declared 24 crashes "reproduced" — including tests that pass.
- A fallback chain with no floor needs a third branch, and it is a refusal.
- Assert the tools before running anything and exit with the setup status, not 24 crashes.
croncalls a wrapper, redirects with>> file 2>&1in that order, appends rather than truncates, and escapes%.- Print the matrix change beside a trend's counts. A trend over a changing matrix is two measurements on one axis, and a chart is more persuasive than a report.
- Keep
results.tsvoutside the archive, so a year of trend data is ninety small files and no unpacking. - 151 files, 8.5 KB of content, 604 KB on disk — 72×.
tar.gzis 2.3%; deleting the passing logs is 6%. Compress, do not prune. - Delete whole regressions on age, because half a regression answers nothing. Prune waveforms only, and by measured size.
- Read the archive back and count before deleting, refuse to overwrite, refuse to archive an unfinished run — and compare entry counts, not file counts.
-mtime +90skips day 90.- A nightly flow decays without being edited. Chapter 35.8 measures the decay that prints the same line and the same status while two of five blocks stop being tested.
8. What Is Next
Module 32 has turned Module 31's mechanics into a system that runs unattended, decides each verdict from evidence rather than from one number, records enough to act on, returns a status a machine can gate on, and keeps a year of history in a few megabytes.
What it cannot do is tell you why a run failed. Every class this module reports — FAIL, TIMEOUT, CRASH, NO_VERDICT, INCONSISTENT, VACUOUS — names a situation and then hands you a log.
Module 33, Debugging Failed Jobs and Broken Flows, is about the case where the flow itself is what is broken: triaging a failure to a layer before investigating it, reproducing one failing run by hand exactly, finding why a command works at your prompt and fails inside the script, and using tracing to recover what a long script actually did rather than what it was written to do.
Where this fits
Part of the Linux & Bash curriculum.
