Engineering record

What the fleet taught, in the order it hurt

Every finding below came out of running the thing, not reading about it. Each one carries the symptom that looked fine, the number that ended the argument, the rule that transfers to somebody else's machine, and where it shipped.

repositories under one standard
91
releases tagged
1231
restore scripts, every one run by CI
73
findings on this page
98

Fleet numbers are recounted by fleet-ops and were last changed 2026-10-13 20:26 UTC. The short version, for someone deciding.

How to read a row. The symptom is what an operator saw and believed. The measurement is what settled it. The rule is the part that survives leaving this fleet. A row with no measurement is not on this page.

Checks that lie · 44

01

The health check that was green over a core dump

Symptom

A game server crashed with a double free and dropped two players mid-round. Docker reported the container healthy for the whole of it, and Docker does not restart an unhealthy container anyway.

Measured

pgrep -f "ZZZ_no_such_process" exits 0 inside that container. pgrep -f searches full command lines, and the shell running the check has the pattern in its own. Seventeen hours green.

Rule

A check whose pattern can match the process running the check is a check that has never failed. Split the first letter into a character class and prove the red case against a real container.

Shipped fleet-wide; proven in tests/e2e-healthcheck.sh

02

Healthy before the thing it checks exists

Symptom

The first version of the same check also matched LinuxGSM's supervising script, which starts immediately.

Measured

The container reported healthy while SteamCMD was still fetching 25 GB and no game server process existed at all.

Rule

A check that passes before the thing it checks exists is worse than no check, because it is believed. Name the binary, not the wrapper.

kf2-server-docker-compose v1.0.0

03

The freshness check that called a newer pin older

Symptom

A daily job compared the pinned version against the upstream "latest release" with !=, and went red on repositories that were already current.

Measured

GitHub's releases/latest is the most recently published release, not the highest version. A backport published after a newer line makes every up-to-date repository look behind.

Rule

Compare versions with sort -V, never with equality. Equality answers "is it different", and the question is "is it behind".

Fleet-wide freshness jobs

04

A silenced probe that returns empty is a probe that lies

Symptom

A host reported its backups current every night. They were not.

Measured

find -newermt failed to parse its own date expression. Its stderr went to /dev/null, the output was empty, and empty meant "nothing changed".

Rule

Redirecting stderr converts a broken probe into a clean answer. If a command can fail, the failure has to be a different value from "found nothing".

The rule, not the code, travelled: it is why --verify below refuses to classify without a cutoff

05

The clean report computed by code that existed nowhere

Symptom

A fleet conformance checker was patched twice, re-run, and reported every repository meeting the standard. That verdict was passed on.

Measured

The next day a fresh clone showed neither patch had ever been pushed. The clean number had been produced by a local copy that no longer existed anywhere.

Rule

A fleet-wide verdict is worth exactly as much as the reachability of the code that produced it. Quote a number only after re-running from a fresh clone.

fleet-ops

06

Check the things that check things

Symptom

Every report on a host was green, and had been for months. Reports read the table of scheduled jobs; nothing read systemd.

Measured

Asking both the same question, "how many timers are scheduled", differed by five. Five timers were firing on a schedule nobody had written down.

Rule

Anything that reports on other things has nobody reporting on it, and its wrongness reads as good news. Ask questions that have two independent answers, and require them to agree. Both sides answering nothing is not agreement.

deadman-switch v1.1.0, as the agree check kind

07

A timer whose service was deleted never fails

Symptom

A scheduled job silently stopped happening. Nothing in systemctl --failed mentioned it, because it never failed.

Measured

The timer fires, systemd finds no unit to start, and the job does not run. Five such timers were left behind by renamed scripts on one host.

Rule

Waiting for a failure cannot detect a job that stopped being able to fail. Assert that every enabled timer still has a loadable service.

deadman-switch v1.1.0, as orphan_timers

08

Two rules that were wrong on their first real run

Symptom

A vendored-config diff reported every file as changed. A README-versus-pin rule reported nothing at all, for months.

Measured

The diff captured file contents with $(...), which strips trailing newlines, so every file differed from its own byte-identical copy. The version rule read a pin of v3.1.0 and a README saying 3.1.0 as different versions, leaving every v-prefixed pin unchecked.

Rule

A rule that has only ever been silent has not been shown to work. Break a conforming repository on purpose, one rule at a time, and fail the suite when the rule stays quiet.

fleet-ops conformance, 25 rules, each exercised against a real violation

09

A hundred repositories, nothing stuck, nothing looked at

Symptom

A check for Dependabot pull requests nobody merged reported the fleet clean. Running the same function by hand against one of those repositories produced a finding.

Measured

The sweep now says what it did: 105 of 105 repositories read, 0 open pull requests seen, while a pull request 45 hours old sat on one of them. Reading worked. Seeing did not.

Rule

A check that examined nothing and a check that found nothing print the same empty result, and the second is the one it exists to produce. Make coverage part of the output: how many it was handed, how many it read, how much it saw.

fleet-ops heartbeat

10

One status code, three different facts

Symptom

The same check had been blind since it was written, and every credential it used looked like it was working. The first fix for it was itself wrong, and shipped.

Measured

GitHub answers 404, not 403, when a credential lacks the Pull requests permission, the same shape as "no such repository". It also answers 404 to anyone when the repository has Issues turned off, because a pull request is an issue. Across all 105 repositories on the account: issues on, the endpoint answered 103 of 103; issues off, it refused 2 of 2. No exceptions either way.

Rule

One status code, three facts. Try the next credential; when none gets further, fetch the repository itself and let it decide: unreadable means nothing can see it, has_issues false means the feature is off and there is nothing to watch, and otherwise the permission is missing. Three sentences, none of them silence.

fleet-ops. Published first as "pull requests cannot be switched off", which was wrong; the correction is the row

11

A run that is red every morning cannot also mean a watcher died last night

Symptom

The job that notices when a scheduled job stops running went red twice a day for two days, over a permission nobody was going to widen that morning.

Measured

Every one of those runs was true and none of them was new. The finding was filed, the fix belonged to a person, and the red it produced was indistinguishable from the red that means something stopped overnight.

Rule

Separate a finding that is true from a finding that is new: report both, let only the second reach the exit code. Standing is earned against a written baseline, never by the job itself, and the baseline has to be able to lose names, or a list that only grows ends up accepting anything.

fleet-ops heartbeat; scripts/known-gaps.txt, empty again the day the gap closed

12

The designed alarm, read as a verdict

Symptom

An automation that refreshes pinned image digests pushed a change, read its CI, and reverted it. The change was good.

Measured

In the run that condemned it: docker compose up and the HTTPS smoke test passed, all three Trivy scans passed, the linter passed. The only red job was the freshness check, which goes red the moment any pin in that repository lags upstream, and says nothing about whether the digest just written boots.

Rule

A run's overall conclusion cannot tell a designed alarm from a failure. Read which jobs failed. And decide which way to be wrong: a list of jobs that could not be read must count as a real failure, because a revert thrown away is recoverable and a broken pin left on main is what the pass exists to prevent.

fleet-ops triage; six cases against freshness_only_failure(), and the reverted refresh re-applied and released

13

The alarm that has to be believed, crying wolf

Symptom

The job that exists to notice a stopped schedule reported one: a daily verification had not fired for four days. It had fired that morning, and on each of the three days in between.

Measured

The date-filtered query for that workflow's scheduled runs came back with nothing newer than the 18th while runs from the 19th, 20th, 21st and 22nd sat in the same repository, the last of them finishing two hours before the run that asked. A date filter had already been added for this exact failure a week earlier. It made it rarer, not impossible.

Rule

Asymmetric evidence deserves an asymmetric check: a record that exists proves the thing happened, while its absence proves only that one query did not return it. Confirm a silence against a second, independent ask and keep whichever answer is newer: that can remove a false alarm and can never invent one. Spend the extra request only where it could change the answer.

fleet-ops heartbeat. In this file a false alarm costs more than a missed one: its whole value is that somebody believes it when it says a watcher stopped

14

It said it had filed the report

Symptom

A job that files findings into an issue printed opened a new thread for two repositories and the run went green. Neither issue existed.

Measured

The credential could not write there: the API refused with Resource not accessible by personal access token. The create ran unchecked, so the message after it was printed on the way past. Two reports lost, zero signals raised, by the one piece of machinery whose entire job is raising them.

Rule

A reporter that cannot tell whether it reported is worse than no reporter: it converts a delivery failure into a clean run. Check the call, fail on refusal, and print the undelivered report so it is at least in the log rather than gone.

fleet-ops file-report.sh; shown a violation: swallowing the status again fails three assertions

15

A name is not an identity

Symptom

A new check compared what a fleet had released against what two machines were running, matching their files by name. Its first run produced four findings and read as a success.

Measured

Three of the four were false. A script with the same filename as a published tool carried none of that tool's settings, zero occurrences of the variable the finding was about. Two others were each machine's own version of a job, already internally consistent: the directories they copy with no exclusions are verified with no exclusions.

Rule

Matching by filename across machines that name things their own way produces confident nonsense. A row must declare a string that has to be inside the file before it applies at all, and a file without it is reported as a different tool of the same name, which is a different answer from a tool that has not been updated.

fleet-ops fleet-to-hosts.py; the identifier ignored again fails an assertion

16

A host that leaves the table stops being visited

Symptom

When those three false findings were withdrawn, one machine had no rows left, so it dropped out of the table the job iterates. The thread already open in that machine's own repository kept listing the two withdrawn findings, and nothing was left that would ever look at it again.

Measured

The roster of machines was derived from the rows. Zero rows, zero visits: the report went silent in exactly the state where it had the most to correct, and silence on an open thread reads as a standing complaint.

Rule

Who is checked is not the same list as what is still outstanding. Keep the roster as its own list, visit everything on it whether or not anything applies, and make a row naming somebody off the roster an error rather than a quiet extra visit.

fleet-ops to-hosts-list.py; six assertions, including a document whose roster is missing entirely

17

A wait for zero always succeeds

Symptom

A deployment script recreates every backend and then waits until the number of healthy containers reaches the number of backend directories. Run where that glob matched nothing, it printed ALL BACKENDS HEALTHY and exited 0 having recreated nothing at all.

Measured

The expected count was 0, and 0 >= 0 passes on the first pass. The glob matched nothing because a failed cd was not fatal: the script sets -uo pipefail without -e, so the error printed and everything below it ran in whatever directory the script had been started from.

Rule

A count-based wait needs a floor, because zero of zero is the one case where waiting proves nothing. And a cd whose failure is only a warning turns every path below it into a path somewhere else.

open for review on the host it was found on, with the registry check that found it

18

A mirror built by a snapshot cannot be edited through the mirror

Symptom

Two machines keep their configuration in git repositories that look identical from the outside: same host, same layout, same commit rhythm. A fix was prepared for one of them as a pull request, and the pull request said that merging it would reach the running machine.

Measured

Half of that repository is not the working tree. A snapshot script runs before every commit and copies the live files over it, live to repository and never back, and deletes anything the sources do not have. A merge into that half survives until the next run, at most an hour, and the revert is committed under the name config drift, which does not mention what it undid. The repository’s own README records the mechanism doing exactly that three times in one day, the third time to the warning about it that had just been written there.

Rule

Before proposing a change to a machine’s configuration repository, establish which half you are in: a working tree, where the merge is the deployment, or a snapshot, where the fix has to be installed to the real path and the mirror carries it back on its own. Both answers are correct somewhere, they are indistinguishable from the pull-request page, and only one of them is a change that lasts an hour.

both host pull requests rewritten with the sequence that actually applies; the rule is in the fleet’s host-facing document

19

The proxy builds the route, then throws it away

Symptom

A published stack redirects its apex domain to www. For anyone who deployed it the documented way, that redirect had never once fired. Every container healthy, no failed unit, nothing in any report.

Measured

The variable naming the apex was referenced by the router and was not in the example environment file everyone is told to copy, so the rule rendered as Host(``). The proxy accepts that configuration, creates the load balancer, creates the server, attaches its middlewares, and only then discards the router: error while checking rule Host: empty args for matcher Host, [], one line, at debug level. Run against the same file, the compose tool says it out loud first: variable is not set. Defaulting to a blank string.

Rule

An empty string is not a missing value to most tools; it is an accepted one. A variable that cannot have a sensible default has to be guarded so that empty stops the deployment and names itself, and one that can have a default must carry it where the file is read, not only where it is documented.

wordpress v1.9.0 (guarded and documented), outline v2.0.7 (upstream’s own default in both places), and a fleet conformance rule that reads every compose file against the example file it ships, run against the release that carried the defect, it names the variable

20

The verification deployed a stack nobody deploys

Symptom

Sixty-four published stacks have a job that boots the whole thing on every push and smoke-tests it. Green for months, over a defect that every real deployment had.

Measured

The job wrote its own environment file, eight lines of it. The documented path is to copy the example file and edit it. Those are two different deployments, and they differed by exactly the variable that was broken: the job had it, the example file did not. Checked across the fleet, 64 stacks ship an example file and two required a variable it never mentioned.

Rule

A verification has to start from the artifact the reader is told to copy, and override only what it must own, credentials that have to be throwaway, hostnames it controls, timings that let it finish. Anything it supplies that the documented file lacks should be printed as a warning rather than passing unnoticed, because that difference is where a whole class of defect lives.

wordpress deployment-verification builds its environment from the example file; the step’s own script was run against the repository before it shipped. The rule that closes the class for all of them lives in the fleet checker rather than in sixty-four workflows: one place, four assertions, and nothing new for a repository to carry

21

A generated page kept saying how often it was generated

Symptom

The catalogue page carries one sentence about itself: how often it is rebuilt. That sentence was a constant in the generator while the schedule was a line in a workflow, and the schedule moved twice.

Measured

The page announced every four hours through a change to twice a day and then to once a day. The same words sat in the profile summary, in the watcher’s own docstring and in two workflow comments. Nothing compared any of them to the cron.

Rule

A page that describes its own machinery has to read the machinery. Derive the sentence from the schedule, expand a step expression rather than counting commas, because */4 is four times the runs of 6. And when the schedule cannot be read, say that instead of a number.

fleet-ops fleet-catalog.py, sixteen assertions in a suite the page had never had; the constant put back fails two of them

22

Nine of ten red badges were a pin one version behind

Symptom

The portfolio’s front page shows each repository’s own CI badge. A visitor reads red as a template that does not work.

Measured

The workflow behind that badge also runs the freshness check, which is a designed alarm: the pin is behind upstream and the fleet’s triage moves it within the day. Over fourteen days: 281 failed runs on the default branch, 254 of them with no failing job except that alarm, and 27 real. At the moment of writing, 64 badges green and none red.

Rule

A designed alarm and a broken build must not share a signal that other people read. Either the alarm gets its own workflow, or the only place it is allowed to be red is the thing that consumes it, an automated triage that already tells them apart.

split in all 64 templates on 2026-09-23, in stages: the triage and the conformance check taught to read either layout first, three canaries of different shapes, then the rest. 64 of 64 verifications green on the new heads; the first red freshness afterwards was a real lag, and that template’s badge stayed green while it was reported

23

The robot and the alarm agreed by coincidence of wording

Symptom

An automated triage moves a pin when a check reports it is behind. It learns that by reading the check’s log. Every template writes that sentence itself.

Measured

One pin sat a patch release behind and was reported by hand every day, because its check says is behind its line: pinned 7.0.30, latest in 7.0 is 7.0.31 while the triage matched a different phrasing. Tested across the fleet by rendering every alarm’s text and running it against that matcher: 51 alarms it could act on, 51 it could not, including every proxy pin in the fleet, whose sentence says “pinned minor 3.6” and where the word minor was the whole problem.

Rule

When one program reads another’s prose, the agreement is a contract nobody wrote down and nothing tests. Parse the facts, the two versions, rather than requiring a sentence, and test the parser against the real text of every producer, not against the one example in front of you. A line that carries no second version must still match nothing.

fleet-ops fleet-triage.sh: the parsing is its own function above the test seam, seven cases on the real sentences, and the old wording put back fails three

24

A cause published without being measured

Symptom

The pin was moved by hand and the release note said why: the tag carries a flavour suffix, and the automatic bump only understands a bare version. It was a reasonable reading of the code.

Measured

It was wrong. The suffix had nothing to do with it; the alarm’s wording did, which the finding above measured an hour later. By then the sentence was in two changelogs, two published releases and a closed report.

Rule

A cause is a claim, and a claim in a release note travels further than one in a chat. Measure it before it ships, and when it turns out wrong, correct every copy rather than the one that is easiest to edit: the changelog, the published release body and the thread.

both changelogs, both release bodies and the closed report now carry the measured cause and say plainly that the first one was not it

25

The linter passed a garbled string

Symptom

A script that moves a job between workflow files rewrote one line of the configuration it copied, and every check on the result passed.

Measured

The rewritten concurrency group read pin-freshness-${{ github.ref }} github.ref }}: the pattern had stopped at the first space and left the tail of the old value behind. The workflow linter passed it, because a garbled string is still a valid string. It was caught by reading the output, not by any gate.

Rule

A mechanical transformation is verified by comparing its result to its input as data: every job, trigger, permission and environment key equal, and anything that was meant to change changed to exactly the intended value. A linter answers whether the file is well-formed, which is a different question.

the move refuses to write unless everything compares equal; putting that first draft back fails eight of its fourteen cases

26

The checker had the defect it was looking for

Symptom

An audit written to find restore scripts that disagree with their stacks reported six, and a seventh was then spotted by eye.

Measured

The audit skipped every value containing a $, and the newer generation of scripts writes paths as ${VAR:-default}, so it passed that generation unread. It also recognised a one-word variable prefix only, and where it could not read a stack’s paths it compared nothing and said nothing. Taught both, it reported eight and named the two repositories it could not compare instead of passing them.

Rule

A checker is a program with the same defects as the thing it checks. Plant the case it must catch, and make it say out loud where it could not look: silence from a checker is indistinguishable from a clean result.

the audit that drives the restore-script wave

27

The release flag followed the calendar backwards

Symptom

The catalogue showed one template at v1.3.0.

Measured

v2.0.4 was out. A release had been created late for an old version that was announced and never tagged, and the host’s “latest” flag goes to the most recently created release, so the older version took it. The page read the flag.

Rule

“Latest” means the highest published version. Compute it from the releases, and use the flag only when no tag parses as a version.

the catalogue computes it now; the flag itself moved when the next release was cut

28

The test restored correctly. The script did not.

Symptom

Two templates' restore tests passed on every run.

Measured

One test cleared the data directory before unpacking, with a comment explaining that tar does not delete. The other put its marker in a collection the archive already held, with a comment explaining that mongorestore --drop replaces only those. Both tests knew the limitation and worked around it. Neither shipped script did. The green run proved the test's restore.

Rule

When a test has to work around the thing under test, the workaround is the finding. Move it into the artifact, and have the test fail the old version.

portainer and rocketchat templates; the Rocket.Chat test now also requires a collection created after the backup to be gone

29

An apostrophe in a comment stopped every backup

Symptom

A backup loop's rewrite passed lint and every local check.

Measured

The loop runs inside bash -c '...', and a new comment said Garage's. The apostrophe closed the quote; bash stopped at unexpected end of file and no backup ran. It was caught because CI waits for a Data backup OK line rather than for the container to be up.

Rule

Parse the command the way the runtime will see it: render the compose file and run bash -n on the string it produces. Lint of the YAML is not lint of the script inside it.

outline template, found by its own CI and fixed the same hour

30

A drill that crossed from v1.9.1 to v1.9.1

Symptom

The first clean-machine restore passed and reported an older release restored into a newer one.

Measured

Both sides ran the same stack. The drill took "the release before main" by tag order, and main was one commit past v1.9.1 with nothing in the stack changed, so the "previous release" was the current one. A version-crossing test that did not cross passed exactly as a real one would.

Rule

Define "previous" by what differs, not by what came before: the newest release whose stack is not the one being tested. And print both ends of the crossing in the result, where a reader sees them.

the clean-machine drill, before its first published number

31

The rule that called a running script unrun

Symptom

The fleet rule that every restore script runs in CI reported the fleet clean.

Measured

The live counter written from the same test said 72 of 73. The 73rd, in restore-drill, runs in its test as "$ROOT/restore-drill.sh", and the rule only recognised ./restore-drill.sh. The rule had not been wrong about any repository it judged; it would have been wrong about the next one written that way.

Rule

Publish a count next to a rule, and reconcile them. A number that disagrees with a green check is the cheapest bug report there is.

fleet-ops conformance and the profile's live line, both now accepting a path to the script, with a test for each form

32

The fake that failed the way the real thing never does

Symptom

A new check in the fleet's heartbeat asked every private repository for its schedules and caught CalledProcessError around the call, so a repository the token could not read would be skipped. 116 tests passed. The test's fake gh raised exactly that exception.

Measured

The real gh() never raises: it calls sys.exit. In CI the fleet token cannot read eleven private repositories, so the first of them would have ended the whole run at 07:15 the next morning, with every unit test green. Found by reading the function the fake stood in for, an hour after pushing, before the schedule fired.

Rule

A fake must fail the way the real thing fails, or the tests prove the wrong contract. Read the callee before trusting a green test that only exercises its stand-in; where a function exits instead of raising, give the caller a variant that answers None and keep the exit for the credential that died.

fleet-ops gh_or_none(); the fake now returns None, and a test ends the run with SystemExit to prove the loop survives it

33

Sixty-two hours late, measured from a schedule that no longer existed

Symptom

Three templates moved from a weekly cron to a daily one at 15:41. The heartbeat, run the same evening, reported all three as late.

Measured

Their last run was the weekly one, two days earlier. Against the new 24-hour period that read as 62 hours late, before the new daily time had come round once. A cron edited is a clock reset, and the check measured from the old clock.

Rule

Lateness is measured from the later of the last run and the last change to the workflow file. A schedule that changed since it last fired has not missed anything yet; a schedule changed long ago and silent since is still a finding, and both cases are tests.

fleet-ops heartbeat, last_changed(); the planted version reports the three, the shipped one reports nothing until a daily run is actually missed

34

A file pattern that starts with a dash is an option

Symptom

The clean-machine drill went out to 46 templates in one afternoon. SQL Server's was the one that waited fifteen minutes for a backup that, by the container's own log, was landing every sixty seconds, three databases at a time.

Measured

Its dump names carry the database, so the drill's pattern was -dr_drill-…\.bak$, and grep -E "$pat" read the leading dash as a flag: no error to the caller, no match, a loop that saw nothing. Every other template's pattern began with a bracket or a caret and never met the bug.

Rule

A pattern that comes from configuration is passed with -e, and a wait loop that finds nothing prints what it looked for and where. The message that ended the search carried the pattern; that is what settled it in one read.

tests/dr-drill.sh in every drilled template, 24 September 2026

35

The marker that was never written, and the drill that said nothing

Symptom

The last template to join the clean-machine drill, Outline with Keycloak, failed twice with the marker file coming back empty. Database marker fine, application answering, keycloak and outline dumps restored, attachments restored, and one empty string.

Measured

On the dying host, running the previous release, the drill wrote the marker into $OUTLINE_DATA_PATH/data; that release kept its data under /data, not /var/lib/garage, so the directory did not exist and the shell said cannot create … Directory nonexistent. The drill's write ended in || true, so the log line was there and the run went on to fail twenty minutes later on the read, blaming the restore.

Rule

A step that sets up the proof cannot be allowed to fail quietly: a marker that could not be written ends the drill there, with the path in the message. And the path is recorded, not recomputed: the clean machine reads where the dead host wrote, because two releases may keep the same data in different places and a backup taken from the root restores files where they were.

tests/dr-drill.sh, every template: mark_file_write fails the run and records its path in the exported set; Outline restores v2.0.7 into v2.1.0 in 148 s; 25 September 2026

36

The policy that sent encrypted reports to a page that did not exist

Symptom

Every SECURITY.md in the fleet, since April: "Encrypted email is preferred; the PGP public key is published at heyvaldemar.com/security." Ninety-seven repositories, one sentence, and a reader who followed it got a 404.

Measured

The sentence was written before the page, the page was never made, and nothing checked that a URL in a policy answers: the profile-claims check reads the profile, the conformance check reads the templates, and neither opens a link. It surfaced on 25 September while answering the OpenSSF Best Practices question that asks for exactly that URL. There was no key to publish either.

Rule

A policy names a channel that exists. GitHub's private vulnerability reporting is now on in every repository and named first; the page exists and describes the process; a security.txt carries the same contacts; and the heartbeat opens every URL in every SECURITY.md and reports one that does not answer.

heyvaldemar.com/security and /.well-known/security.txt; private vulnerability reporting enabled on 97 repositories; the policy rewritten fleet-wide; the heartbeat rule; 25 September 2026

37

Fifteen green CI badges that were a linter's opinion

Symptom

Fifteen repositories carried a green workflow badge and a README line saying the repository is verified by CI on every push. Their workflows ran actionlint, tflint, tfsec, shellcheck, hadolint or a Docker build. No step could fail because the thing the repository is for did the wrong thing.

Measured

The OpenSSF Best Practices questionnaire asks seven questions about an automated test suite. Answered honestly for 89 repositories on 25 September: 74 have one, eight Terraform pipelines, six tools and the one published image have none. Lint is a check on the text; a test is a check on the behaviour, and green on the first was being read as the second.

Rule

A CI badge on a repository with no test says lint, not verified. Where a test suite is missing the questionnaire says Unmet, the badge reads in progress, and the evidence page names the repository; the word verified is reserved for a run that can fail on behaviour.

The badge answers for 89 repositories; the evidence page's list of the fifteen, read daily from the badge site; decision 18; 25 September 2026. On 26 September all fifteen got a test suite that is shown to fail; and the entry on a dry run that reported a file it never wrote has what they found

38

Three fixtures cut from a living reference, and the rule they tested stopped firing

Symptom

The conformance suite's three cases for a restore script CI never runs were cut from the Nextcloud template at test time: copy the repository, strip every line that runs a restore script, expect the finding. On 24 September the clean-machine drill workflow was added to every template, and it names the restore scripts too. The fixtures now had nothing unrun. The rule under test stopped firing, and the suite said so: Verify was red for fourteen hours.

Measured

Every scheduled job was green through those fourteen hours, and the heartbeat, which reads scheduled runs only, said the machinery was fine. Verify runs on a push, not a cron. With mail off, a red push run in a private repository reached nobody until a person opened the Actions tab.

Rule

A fixture cut from a living reference has to strip everything the rule could count, not the places the rule counted last month; and a push-triggered workflow red on main is a heartbeat finding like any other. The heartbeat now reads the latest push run of every push-triggered workflow here and names one that ended in failure.

fleet-ops test-conformance strips the workflows too; the heartbeat's red-on-main rule, planted in its test; 25 September 2026

39

Twenty-three good refreshes reverted in one run, because "skipped" was read as red

Symptom

The triage pushes a digest refresh, and the next run judges it by its CI answer: green cuts a release, red reverts. At 13:10 on 25 September the next run reverted twenty-three refreshes at once, every one of them on a commit whose deployment verification had passed an hour earlier.

Measured

Two days earlier the freshness check had moved out of the verification workflow into its own, which runs on a schedule and skips on a push by design. The refresh pass recorded that workflow for the judgement. The answer it read was skipped, and the branch that handled every answer but green, running and cancelled was the revert. One code path, twenty-three reverts, each followed by "needs a human".

Rule

A pushed change is judged by the workflow that verifies it, never by the one that reports on something else; and an answer that is not a verdict on the change (skipped, neutral, cancelled) keeps the change and judges HEAD, because only a red run has said anything. The mapping is one function with every answer planted in its test.

fleet-ops triage: the pending row carries the verification workflow, pending_action maps every answer, nine cases; the twenty-three repositories re-refreshed by the next run; 25 September 2026

40

A test that slept for a number, and a good upgrade reverted twice for it

Symptom

The Nextcloud template's backup test places a file dated 2020, waits, and expects the prune to have removed it. On 25 September the digest refresh that moved Nextcloud to 35.0.1 failed that test, was reverted by the triage, failed again on a rerun, and was reverted again. Nothing about pruning had changed.

Measured

The test slept the backup interval plus sixty seconds: 120 s in CI. A backup cycle is a database dump, a data backup that takes as long as the data does, the prune, then the interval. With 35.0.1 the data backup took 98 s; the cycle ran 160 s; the test asked at 120 s, before the prune had run. find and touch in the old and new images behave identically, checked locally. Thirty-six templates carried the same three lines.

Rule

A test waits for the event it is testing, not for a number somebody chose when the data was small. The prune test now polls for the file to go, with a ceiling of two cycles, and prints how long the prune took, so the day the ceiling is reached the log says so in seconds rather than in a revert.

The polling test rolled out to 36 templates; thirteen without a prune test are named in the rollout's output; 25 September 2026

41

An honest partial count, published as the fleet

Symptom

For an hour on the night of 26 September the profile said 24 restore scripts across 15 repositories, and the evidence page showed no clean-machine restores at all. The fleet had 73 scripts across 48 repositories and 47 restores measured that week.

Measured

The public copy's catalog run met GitHub's hourly API limit half way through reading the fleet, during the hour the fleet's engine moved to its public repository and every job ran twice; the private copy's run in the same minute read everything. The public one read 37 of 97 repositories and published what it had. The evidence line even said that 60 could not be read, which made it honest and still wrong: every visitor's browser replaced the page's numbers with the smaller ones, and the site's copy job, which refuses a fleet that shrank by a fifth, was not due for three hours.

Rule

A partial read is not a smaller fleet. The catalog reads everything before it writes anything, and publishes nothing when more than three repositories could not be read or the restores it measured fall below half of the last published file; the browser keeps the numbers the page was built with when the file is partial, and the copy job refuses one. Three layers, because the first one to meet a partial file was the one nobody had thought to guard.

fleet-ops catalog with seven planted cases; the site's live refresh and daily copy; the numbers restored by a whole run the same hour; 26 September 2026

42

A dry run that reported a file it never wrote, found by the tests the badge asked for

Symptom

The Active Directory cleanup script tells its reader to run it with -WhatIf first. Under -WhatIf it printed how many dormant accounts it had written to its report, and wrote no report. The terminal tree's Ctrl-C handler called tput reset, which wipes the scrollback and, on a terminal that does not answer it, stalls long enough to be killed before the cursor is shown again. The dry run had shipped for three weeks, since the script learned -WhatIf on 3 September; the handler, since 2021.

Measured

Both surfaced on 26 September, on the first run of test suites written for the fifteen repositories decision 18 left short of the OpenSSF badge. New-Item and Export-Csv inherited the dry-run preference along with the account changes. Every suite came with planted violations, 153 in all, and the plants found four tests that could not fail: a -WhatIf case that Remove-Item satisfied on its own, a Mattermost plant no test could reach, a tree-size check that needed to measure one row, and an SSH host-key guard sitting behind a second lock, so removing it changed nothing a test could see. That one became a man-in-the-middle test: after the key scan the target's network name moves to an impostor with fresh host keys and the same account, and the job has to refuse to log in. Writing the Terraform tests moved three load balancers to AWS's TLS 1.3 policy and put what the defaults do not deliver into each README: SSH open to the internet on four EC2 pipelines, Redis unencrypted in two, a public EKS endpoint.

Rule

A test is trusted only after a planted violation has made it fail, in every repository and not only in the fleet's engine. What a configuration's defaults do not deliver is written in its README, beside the test that asserts what they do.

Test suites with planted violations in the fifteen repositories, run in CI on every push; the dry-run report and Ctrl-C fixes; the TLS policy in three pipelines; a release of each; 26 September 2026

43

A release that published its tags and then stopped before signing them

Symptom

The aws-kubectl v2.2.0 run pushed 2.2.0, 2.2, 2 and kube-v1.37.1 to Docker Hub, then failed. Anyone pulling 2.2.0 got the image. Nobody pulling it could verify it: the run ended before cosign signed it, before the build attestation and before the Trivy scan.

Measured

Docker Hub keeps sha-*, version and kube-v* tags immutable on this repository. The release was tagged on the commit main had published minutes earlier, so the tag run rebuilt it and tried to move sha-83be903 to the new digest. The refusal came last, after the version tags were already out. Immutability did its job; the workflow assumed a tag run always builds a commit nobody has built. kube-v1.37.1 now points at the unsigned image for good, because an immutable tag cannot be moved to the fixed one either.

Rule

A release tag emits only tags nobody holds yet: the per-commit tag comes from branch pushes, and kube-<version> is added only when Docker Hub answers 404 for it. A publish that fails is checked for the signature tag, not the version tag, and a release whose image is unsigned says so on its own page.

aws-kubectl v2.2.1, signed and scanned; the v2.2.0 release page pointing to it; 26 September 2026

44

A user's question that waited three days, because nothing read the issue trackers

Symptom

On 23 September a Keycloak user opened #45: Traefik's timeouts could not be tuned without replacing the whole command, and replacing it means no more updates. The request was reasonable and the fix was a day's work. Three days later it had no reply.

Measured

The heartbeat read schedules, tokens, Dependabot pull requests and heads with no check on them, once a day across 94 public repositories. No job read what a person had written. Answering the issue meant looking at the rest of the fleet: 40 of the 50 Traefik stacks gave an operator no way to set the entry point's timeouts, nine had them under nine different names, and one had only the read timeout.

Rule

A person outside the fleet who has waited more than 48 hours, with no reply or with the last word theirs, is a heartbeat finding, with six planted cases telling that apart from an answered issue, a fresh one and a bot. Every Traefik stack takes TRAEFIK_READ_TIMEOUT, TRAEFIK_WRITE_TIMEOUT and TRAEFIK_IDLE_TIMEOUT with Traefik's own defaults, a stack's older name nested inside so it keeps working, and conformance fails a stack that does not.

Keycloak v1.8.0 and a release of every other Traefik stack, each after its deployment check passed; the heartbeat and conformance rules in fleet-ops; the reply, and the issue closed as done, on 27 September 2026

Backups that are not backups · 18

45

BusyBox tar exits 1 when it cannot write the file

Symptom

Backup sidecars on alpine-based images reported success and left archives that could not be opened.

Measured

The success condition accepted exit 1 because GNU tar returns it for "a file changed while reading". BusyBox also returns it when the output cannot be opened at all. Blocking the destination on purpose produced two unreadable files named like backups.

Rule

An exit code shared by a harmless case and a fatal one is not a result. Read the archive back with tar -tzf before renaming it into the name a restore would pick.

18 repositories, patch-released; now a conformance rule

46

Killed mid-dump, and the restore path picks the corpse

Symptom

A backup loop wrote straight to its final filename. Interrupted, it left a truncated file under exactly the name a restore selects.

Measured

Reproduced on a bench: killed mid-dump, the old loop leaves a file that fails gzip -t. Five templates then failed CI because their success condition still tested the final name, which the fix had moved away from.

Rule

Write to .partial, verify, then rename. And when you move the write target, the tests that assert on the old one become the next bug.

31 repositories

47

"Missing from the copy" is two different facts

Symptom

A verification compared a source tree against its copy and produced a 221-line report of files "missing from the backup".

Measured

Every one of those files was on the disk and identical. The verification ran at 13:00 and the backup at 12:00; the report was an hour of ordinary writes. With the last-success stamp missing, the same code classified every difference as corruption instead.

Rule

Judge each difference against the last successful run: older than it and absent means the backup missed it, newer means it arrived since. With no stamp, name nothing a finding and say why. Unknown time is unknown, not evidence.

external-disk-backup v1.1.0, --verify

48

The drill proved the archive, not the procedure

Symptom

A quarterly restore drill was green for months while the restore command in the runbook was wrong for all ten worlds it covered.

Measured

restic restores to <target>/<path in the snapshot>, so the documented command produced data/data/world/level.dat. The drill looked for level.dat anywhere under the restore directory and found it.

Rule

Restore the way the runbook says, and require the artifact at the same relative path the live system uses. A server that finds an empty data directory does not complain: it generates a fresh world over the one being restored.

minecraft-server-docker-compose v1.5.5

49

A threshold written in the test

Symptom

A database restore test loaded the dump into a throwaway container and passed if the result had more than ten tables.

Measured

Ten is a number nobody revisits. It passes for a dump that restored a fifth of the schema, and it keeps passing as the application grows away from it.

Rule

The live system is the reference. Ask it what it has and require the restored copy to match, naming what did not come back.

zammad v1.7.2

50

The wrong kind of backup before a migration that rewrites every row

Symptom

Upgrade advice said to archive the data directory before a release that re-encodes every identifier in the database.

Measured

That archive is a copy of a live SQLite file with its -wal and -shm beside it, taken by an ordinary archiver. Fine for a daily copy. Not for the one backup that matters, on the day it is the only copy.

Rule

Use the application's own online backup (navidrome backup create, SQLite's backup API) and prove the migration by state afterwards: counts before, the same counts after, every annotation still pointing at a row that exists.

navidrome v1.0.3

51

A floor nobody revisits, in the tool built to catch this

Symptom

A database restore drill loads the newest dump into a throwaway container and passes if the result has at least DRILL_MIN_TABLES tables. Its own documentation asked for that number to be kept near the real schema size.

Measured

Nobody performs that maintenance. The floor passes for a dump that restored a fifth of the schema and keeps passing as the application grows away from it, the same defect this fleet had already found and fixed in an application, still living in the published tool.

Rule

Ask the live system what it has and require all of it back, naming what did not come. An unreadable reference must fail rather than read as nothing missing: a comparison that did not happen is not a clean result.

restore-drill v1.1.0. The suite makes the argument rather than asserting it: the live database grows one table, the drill fails and names it, and the same dump under the old floor passes exactly as it always did

52

An error in the log of an operation that succeeded

Symptom

A backup sidecar printed Access denied; you need (at least one of) the PROCESS privilege(s) on every run, for as long as the template had existed. It was found while reading a log for an unrelated failure; nothing had ever asked about it.

Measured

Against the pinned image rather than reasoned about: mysqldump exits 0, and the dump it writes is byte-for-byte what --no-tablespaces produces, same sha256, 1819 bytes each. The application user has no PROCESS privilege and needs none; the dump was asking for tablespace information nothing wants. The four MariaDB templates were measured too and print nothing at all.

Rule

An error line in the log of an operation that succeeded is worse than no log: it is what teaches somebody to skim past the one that matters. Ask the tool for what you need and nothing else, and settle "is this real" by comparing the two outputs, not by reading the message.

ghost v1.8.7, otrs v1.7.4, the only two of ninety-four that dump MySQL

53

The database with one copy of every character

Symptom

A game-server template shipped a MariaDB service holding every account, character and item on the shard, and no way to copy it. Nothing in its CI or its README mentioned a backup, so nothing reported one missing.

Measured

Of ninety-four public templates it was the only one carrying a database and no backup at all. The game offers no export; the data exists in one volume and is written continuously.

Rule

An absent thing raises no alarm. Enumerate what a standard requires against every repository that should meet it, rather than waiting for the gap to announce itself. It never will.

rathena-docker v1.4.0, with the dump proven to open before it is renamed, and a refused login shown to leave nothing a restore would pick

54

What is saved under another name is verified by nothing

Symptom

A monthly job compares a backup disk against its source by checksum, because the daily copy trusts size and timestamp and cannot see a byte rotting on a disk with no parity. It listed the directories it verifies by name, and reported that every byte matched.

Measured

One library is copied under a different name on the backup disk, films excluded and structure and metadata kept, and that pair was in no list. On a stand-in tree with one metadata file corrupted on the backup side and the source untouched since the last backup, the report was green: every byte matches. With the pair added, the same disk produced one unexplained difference and exit 1.

Rule

A verification that lists names misses the pair whose two names differ. Verify what is saved, not what is conveniently named, and where the copy excluded something, read those exclusions out of the copy itself rather than writing them down twice: a verification that has lost them calls every excluded file missing, and a report nobody finishes reading is the same as no report. If they cannot be read, stop and say so.

open for review on the host; the exclusion list unreadable stops the run instead of flooding it

55

The restore script nobody runs

Symptom

Forty-seven templates ship scripts to restore their database and data from the backups they take. Every one of them has an end-to-end backup test, green on every push.

Measured

Not one of those tests runs the shipped scripts: each restores with its own copy of the commands. Checked against the stacks they belong to, eight scripts disagree with them. One listed a backup directory the stack does not write to, so on the day it was needed it would offer nothing to restore. Several still target the paths of an image family the templates left months ago; one of those refuses to run unless that old path exists, and refuses any override of it. The tests stayed green throughout, because the copy was right and the script was not.

Rule

A restore script must not keep its own copy of where the stack writes: it asks the running backups container, whose environment the backup loop itself reads, so the two cannot disagree. And the test runs the shipped file, non-interactively, against a marker written after the backup it restores. A test that exercises a copy proves the copy.

fixed and proven in CI on the two templates published today; the other eight are the next wave

56

A backup of the volume nothing writes to

Symptom

A team wiki's backup loop logged Data backup OK every cycle, and CI checked that the archive it named opened.

Measured

Since the stack replaced MinIO with Garage in 2.0.0, the loop had archived the old minio-data volume, which nothing writes to. The attachments were in no backup at all. The restore script looked for a service called minio and stopped before touching anything.

Rule

An archive that opens proves the archive. Prove the backup the way a user would notice it missing: store an object through the application's own interface, back up, store a second, restore, and require the first to read back and the second to be gone.

outline 2.1.0: the loop archives Garage's directories, the restore puts back Garage's own metadata snapshot, and CI does that round trip through S3 on every push

57

Forty-seven restore scripts, and CI had run none of them

Symptom

Every template's CI restored a backup on every push and went green.

Measured

The tests restored with their own copies of the commands. The scripts a person would run on the day they need one: four written for Bitnami images on stacks that run official ones, one of which refused to run at all; one that signed in with the wrong account and would have dropped a database that does not exist; restores that merged instead of replacing, through rm -rf dir/*, which keeps dotfiles, tar over live data, and mongorestore --drop, which keeps collections the archive does not hold; paths read from the operator's shell instead of the stack; and a database image with no client of the name the script called, found only when CI first ran it.

Rule

Test the artifact you ship, not a copy of what it does. Then make that a rule the fleet checks: a restore script that no test invokes is a finding, and a comment or an echo that names it does not count.

all 47 templates run their shipped scripts in CI; fleet-ops conformance carries the rule, with a list of exceptions that can only shrink, empty the day it was written

58

On a rebuilt host, the first backup is the one you restore

Symptom

A restore onto a clean machine picked the newest backup in the directory, ran without an error, and brought back an application with none of its data.

Measured

The backup loop starts with the stack. On the new machine its first cycle wrote an empty backup into the same directory before the restore ran, so the newest file was the new host's own. Nextcloud got the new, empty database next to the old files and answered 500; Gitea came back without its files. Backup names carry the minute, not the second, so that first cycle can also overwrite an imported backup of the same name.

Rule

On a rebuilt host, restore by name, from the files you brought with you, never by "newest". Keep the backup loop's warm-up long, or the loop stopped, until the restore has finished.

the weekly clean-machine drill in WordPress, Gitea, Nextcloud and Keycloak, which restores the newest imported file by name

59

A dump does not carry the account the application logs in with

Symptom

Nextcloud's backups restored cleanly in CI on every push, database and files, and the restore scripts ran without an error.

Measured

Restored onto a machine that had never run the stack, it answered 500 on every request: password authentication failed for user "oc_admin". Nextcloud's installer creates its own database account with a random password and writes it into config.php; pg_dump saves the data and none of the server's accounts. The new host's installer had created that account again, with a different password. Every same-host restore passed, because there the account already had the right one.

Rule

A backup is proven on a machine that has never seen the stack. Anything the application needs from outside what the dump saves (server accounts, keys, config) is either in the backup or recreated by the restore, and only the clean machine tells you which one you forgot.

nextcloud and nextcloud-onlyoffice: both restore scripts bring the account in line with config.php, as the installer would; the weekly clean-machine drill proves it

60

What an operator keeps off the host is more than the .env

Symptom

The drill's clean machine borrowed only the .env from the dead host and started the stack. Authelia's Postgres refused to initialise, Dozzle's environment step failed to write a password hash, Audiobookshelf's permission job exited 1. Thirty-six other templates came back fine.

Measured

Each of the three prepares something before the stack: a directory of secret files, a bcrypt hash made by a documented command, a bind directory chowned to the container's uid. CI does it in steps the drill had not copied, so the clean machine was cleaner than any real machine would be. Authelia's secrets are also the one thing that must come from the dead host, not be made anew: the encryption key in them is what the restored database was written with.

Rule

A restore test prepares the new host exactly the way the deployment documentation does, borrowed verbatim from the same CI steps, and the drill names what has to be carried off the host besides the backups and the .env. For Authelia that is config/secrets; the list is in the workflow where a reader can see it.

the drill generator borrows every step between the network creation and the start-up; DR_KEEP carries named paths; 24 September 2026

61

Two routers, one rule: the drill that was red on one cold start in three

Symptom

Mailu was the one template of 46 whose clean-machine drill would not go green. On some cold starts /admin/ and /sso/login answered 302 to Roundcube's sso.php and then 404 for as long as the stack ran; on others everything answered. I published a cause the same hour, an address swap after the restore, and the next run disproved it: the fault was there before any restore.

Measured

On this machine, main cold-started and asked directly, front answered /admin/ with 302 to /sso/login every time, so front was fine. The difference in CI was Traefik in front of it. The compose file gave webmail its own Traefik router with the same Host() rule as front's and no priority, and Traefik chose between two equal routers at every start; when it chose webmail, every path on the mail host went to Roundcube. One start in three, and every deployment of the template had the same odds. Read side by side, the two routers were the whole finding; the drill only made it repeatable.

Rule

A cause is a claim until the run that isolates it has passed, and the drill now proves the empty stack answers before it puts anything back, so a start-up fault and a restore fault are two log lines. Two routers on one rule is a coin toss at every start, and a stack that behaves on one start and not the next is asking for its routers to be read.

webmail's router removed (front serves /webmail itself, with the single sign-on); the drill green on the next run; the restore script's front restart kept and labelled a precaution; 24 September 2026

62

A prune that was only intended, in thirteen templates

Symptom

Every backup sidecar in the fleet prunes archives older than a number of days: one line of find -mtime -delete. Thirteen templates shipped that line with no test of it. Zammad's backup script had a retention setting since the day it was written and nothing had ever asked whether it deleted anything, or whether it stopped at the recent archives.

Measured

Surveyed on 25 September after the Nextcloud prune test failed: thirty-six templates polled for the prune with a wait chosen when the data was small, nine polled with a ceiling of interval plus ninety seconds, one slept a fixed forty-five, two checked it their own way, and one, Zammad, did not check at all. A file dated 2020 placed beside Zammad's real backups was gone after the next cycle and six recent archives stayed, which is the first time that sentence could be written.

Rule

A compose file that prunes needs a test that exercises the prune, on a line that is not a comment: the fleet conformance check now fails a template that has the one without the other, planted both ways. A prune that is only intended fills the disk; one that takes everything is a backup that is not there on the day.

Zammad's prune test; the two-cycle ceiling in the ten templates that still waited for a number; the conformance rule with two planted cases; 25 September 2026

Containers meeting the filesystem · 7

63

A directory the application renames must never be a mount point

Symptom

Every save a game server wrote after an upgrade went nowhere, once a minute, until a person opened the save directory six minutes later for an unrelated reason. Container running, process alive, save line in the log, health check green.

Measured

The server publishes by renaming: write Saves.next, move Saves aside, rename the staged copy into place. rename(2) on a mount point returns EBUSY; BusyBox's mv says can't rename '/app/Saves': Resource busy. The fallback empties the host directory file by file and the rename in still fails. The only complete world lived in the container's writable layer.

Rule

Mount the parent, not the directory the application renames. Ask of every volume line: does the application ever rename this path.

modernuo-docker v1.3.2, with 19 CI assertions that need no running shard

64

Traefik does not buffer, and a zero turns it on

Symptom

Two templates shipped a buffering middleware with all limits set to zero, and a comment saying it disabled buffering.

Measured

On a bench against an origin that produces its response over four seconds: without the middleware the first byte arrives at 0.03s; with the all-zero one, at 4.15s. Zero means no size ceiling, not disabled.

Rule

Attaching the middleware is what turns buffering on. For a request that is being cut off, raise the entry point's readTimeout. And read the router's API back rather than trusting what a label says it built.

jellyfin v1.0.1, forgejo

65

cap_drop: ALL takes away the right to enter a directory

Symptom

A hardened container running as root could not read a 0700 directory owned by another user, with no obvious reason in the logs.

Measured

cap_drop: ALL removes DAC_READ_SEARCH. Root without it cannot traverse a directory it does not own.

Rule

Add back DAC_READ_SEARCH, which grants read and traversal, rather than DAC_OVERRIDE, which grants write as well.

Fleet hardening

66

A read-only mount that fails silently

Symptom

nginx started, logged nothing unusual, and served nothing.

Measured

Its entrypoint renders the routing table into conf.d at start. Mounted read-only, that write fails and is not fatal: nginx comes up listening on an empty configuration.

Rule

A read-only mount is a claim about what the container does at runtime. Where an entrypoint generates configuration, read-only turns a boot error into a silent one.

dify v1.0.2

67

A bcrypt hash that became five digits

Symptom

Basic auth refused a password that was demonstrably correct.

Measured

Compose requires $ doubled in a label. In an unquoted heredoc, $$ is the shell's PID, so the doubled hash was written to disk as a five-digit number.

Rule

Write generated credentials with printf '%s' from a variable, never through an unquoted heredoc.

Several templates; the same trap appears wherever a hash meets compose escaping

68

The permission bug that only exists on a real runner

Symptom

Stacks that worked on a laptop failed in CI with ownership errors on bind mounts.

Measured

Docker Desktop virtualises bind-mount ownership, so a container copying files into a mounted directory succeeds locally and fails on Linux. Four consecutive CI failures came from that gap alone.

Rule

Test the workflow step bodies on a real runner, not just the stack on a laptop. A local success is a statement about Docker Desktop.

Fleet CI

69

A service with no restart policy

Symptom

After a host reboot, a Nextcloud stack came back with its web interface working.

Measured

Its cron sidecar had no restart policy, so after a reboot every background job - file scans, cleanup, notifications - stopped with no error anywhere. The same gap was in another template’s main application, and a template generated from that one today inherited it before it was caught.

Rule

Every service states what happens when it stops. A one-shot job says restart: "no", which is how a decision differs from something forgotten.

fixed and released where it was missing; a daily rule now asks every service in every compose file

Automation that costs more than it saves · 16

70

62 waits, 2 revert decisions

Symptom

A job that updates pinned images pushed a change and then held a runner in a sleep 30 loop until the target repository's CI answered, one repository at a time. It was a third of the account's Actions minutes that fortnight.

Measured

Across the fifteen scheduled runs that still waited, 7 to 14 September: 62 waits and 2 decisions to revert, one of which reverted a good change and one of which failed at git revert. 439 minutes, 29 a run, the longest 104. Counting every run, scheduled and started by hand: 88 waits in 586 minutes. Ninety-seven per cent of the waiting confirmed what was already true. A first count said 78, because every note prints twice in a run's log, once as it happens and once in the report.

Rule

Every wait is a purchase. Write down what was pushed and read the verdict on the next run. Runs went from 29 minutes to about five, and the exposure it bought back was a red pin on main for hours instead of four minutes, on 3% of changes.

fleet-ops, with the pending ledger committed rather than cached

71

Eighteen minutes resizing images that get resized again

Symptom

A site's CI build took nearly twenty minutes on every push, and threw its output away.

Measured

generating optimized images ✓ Completed in 18m 17s out of an 18m 48s build, over 2,991 images. The deploy platform is the build of record and did the same work minutes later. 27 runs a fortnight.

Rule

Name what a check exists to catch. This one catches a broken build, and a passthrough image service still resolves every image and renders every page: a deleted file fails with UNRESOLVED_IMPORT. 19.6 minutes became 1.

heyvaldemar.com

72

The public scanner disagreed with the public claim

Symptom

The profile advertised "commit-SHA-pinned GitHub Actions with per-job permissions". OpenSSF Scorecard scored the same repositories 5.2 to 7.9.

Measured

Token-Permissions 0/10, caused by one file in 83 of 94 public repositories: dependabot-automerge.yml granted contents: write at the top of the file, where it applies to every job including ones nobody has written yet.

Rule

A claim on a public page is checkable by anyone who cares. Grant writes to the job that uses them, and have something re-read the claim: the table on that profile is now verified daily, and a claim with no rule to check it fails rather than passes.

83 repositories; conformance rule; scripts/profile-claims.py

73

Ahead in commits is not ahead in version

Symptom

Two prepared upgrade branches sat as decisions waiting for a human, reported every day.

Measured

One offered 11.0.0 against a main already on 11.0.1. The other offered Nextcloud 32.0.15 against a main on 34.0.3: opening it would have proposed a two-major downgrade of a database that only migrates forward.

Rule

A branch main has overtaken is not a decision, it is rubbish. Compare with sort -V and delete it, saying what main reached instead.

fleet-ops triage; both branches deleted automatically on the next run

74

Nothing can report its own silence

Symptom

Every scheduled job in the fleet fails loudly when it finds a problem. None of them can fail when it stops running.

Measured

GitHub disables a scheduled workflow after 60 days without repository activity. That is a state on the workflow, not a failed run: no red badge, no email, and the profile keeps showing the numbers it last wrote.

Rule

Ask the platform about the schedules themselves, and measure whether a job fired, not whether it passed. A job that runs daily and fails daily is a different fact from one that stopped.

fleet-ops heartbeat, across 94 public repositories and, on its first run, 165 schedules

75

A hash taken over prose that moves on its own

Symptom

Reports comment only when the set of findings gains a member, so an unfixed problem is quiet. Eight of the findings this fleet produces carry an elapsed time.

Measured

last fired 51 hours ago and the same line a day later hash differently, so an unfixed problem would have commented and mailed twice a day for as long as it stayed unfixed, worst on the day a schedule actually stopped, which is the one alarm that must not arrive buried.

Rule

Hash the finding with its durations blanked, and only a number followed by a unit of time: pull request numbers, versions and counts still tell two findings apart. Watch the tool as well as the rule: BSD sed has no \b and fails that match silently, so it would have worked on the runner and not in any local check.

fleet-ops file-report.sh, shown a planted violation in both directions

76

Dependabot pushing to a repository that cannot hold a pull request

Symptom

Two repositories had Dependabot configured, scheduled, and running. Neither had ever received an update from it.

Measured

Issues are off on both, and a pull request is an issue, so Dependabot pushed a branch and then failed to open the pull request that would deliver it, quietly, on a schedule. The branches were from 2026-09-06 and held checkout, scorecard-action and codeql-action digest bumps. Fourteen days, one of them the profile repository.

Rule

A tool that reports success by opening something cannot report the failure to open it. Where the feature is off, look for what the tool left behind instead: a branch nobody will ever see is the whole signal.

fleet-ops heartbeat; both branches merged and deleted

77

The automation that can only see what happens after it arrives

Symptom

A Dependabot pull request sat green and mergeable for 57 hours in a repository whose auto-merge job was working correctly.

Measured

The job triggers on workflow_run when the verification completes. That verification completed at 07:35:14; the auto-merge workflow reached the default branch at 23:58:59 the same day. Sixteen hours too late to ever hear about it, and nothing re-fires, so the pull request was stranded permanently.

Rule

An event-driven job is deaf to everything that happened before it existed. Anything already in the state it waits for has to be swept once when it lands, or watched by something that asks rather than listens.

Caught by the heartbeat's own stuck-pull-request check; merged by re-running the verification, so the real path proved itself

78

A report that repeats a decision stops being read

Symptom

A weekly scout reads a catalogue of 1,346 projects and proposes the three most worth publishing a template for. Three were read and declined on a Sunday. The next morning it proposed the same three.

Measured

It re-ranks one catalogue and the scores barely move, so the shortlist is stable by construction. Nothing recorded that an answer had been given, so the same question would have arrived every Monday for as long as the job existed.

Rule

A recommender needs a memory of refusals or it is a reminder. Keep the declined with their date and reason, make removing a line the way to reopen one, and say how many are being held back: a filter that silently swallows the catalogue looks exactly like a catalogue with nothing in it.

fleet-ops scout-declined.json. Two of the first three keys were mistyped and would have filtered nothing while the file looked right, so an unmatched key is now reported and the suite holds every key to being a slug of itself

79

A cron here buys an ordering, not a time of day

Symptom

Comments explained the schedule as a sequence: this job at 07:15 because the one it depends on runs at 06:45.

Measured

Measured on 21 September across every scheduled job in the repository, each landed five to six and a half hours after the minute it names: declared 05:00 arrived 10:22; 06:45 arrived 11:47; 07:15 arrived 12:20; 08:00 arrived 14:55. Over the whole month the delay ranges from two to eight hours, by hour and by day. GitHub runs scheduled workflows at low priority and the delay grows with load.

Rule

The reasoning survived only because the shift is roughly equal across all of them on a given day: ordering held while every stated time was fiction. Write down what a cron actually buys on the platform you are on, and anything that needs a real time of day cannot be one.

fleet-ops, in the comment that used to claim otherwise

80

Work that does not exist is the most expensive thing a report can contain

Symptom

A weekly job reads two servers' commit logs and proposes what belongs in the public templates. Its suggestions looked reasonable and were acted on.

Measured

Of twelve pieces of work it proposed, five were already in the target, one of them for eight days. The job was shown only each target's latest release note, so a rule that shipped six releases ago, or lives in the code with no release of its own, was invisible to it.

Rule

A recommendation is only as good as what the recommender was allowed to read about the thing it is recommending against. Give it the changelog, not the newest entry, and remember that work which does not exist is indistinguishable from work that does until somebody opens the repository and loses the afternoon.

fleet-ops lab sweep; the five were checked one by one against their targets and recorded as judged rather than open

81

The checker's universe was a name suffix

Symptom

A conformance checker reported the whole fleet meeting its standard, every day, for months. It was telling the truth about the repositories it looked at.

Measured

It selected by the end of a repository's name. Of 87 repositories carrying a changelog, 21 were never checked at all: every infrastructure pipeline and every published tool that is not a container. One of them had announced three releases in its changelog and carried none of those tags, in public, for four months, with a rule for exactly that sitting unreached inside the checker.

Rule

Whatever decides which things are judged should be a property of the thing, not a convention in its name. And judge every claim, not the newest one: reading only the top entry passes the moment somebody tags the latest and leaves the ones behind it untagged.

fleet-ops conformance; selection is now "carries a changelog", four missing tags created at the commits their changelog dates, and the fleet reports 64 checked

82

A rollout is a point in time, not a rule

Symptom

Five one-shot waves had each fixed something everywhere they looked. A later survey found two of their fixes no longer held.

Measured

The wave that made every verification daily ran on the morning of 2026-09-02; three templates received their CI that afternoon from a template that still said weekly, and the wave’s own log would have read no drift job, skipped. They ran weekly for three weeks under a security policy saying daily and a profile saying every template boots in CI daily. The wave that stopped hiding failed security scans walked the compose templates, and the one pipeline that publishes an image was not a template, so it kept the flag on the scan of what it ships.

Rule

The invariant a rollout establishes has to become a rule something checks every day, over every repository rather than the set the rollout happened to walk. A wave that finds nothing to do on a repository is not evidence the repository is compliant; it is evidence the wave did not recognise it.

both fixed at the source, and both now daily conformance rules; the Trivy rule applies to repositories that are not templates, which is exactly where the last wave was blind

83

Private repositories were outside every wave

Symptom

Four templates had been private since a committed environment file was found in their history. Asked today to publish them, the first one looked finished.

Measured

Judged by the fleet’s standard as it stands, it failed five rules and lacked a supply-chain workflow every public repository carries; its version check compared with !=, which the rest of the fleet had fixed. The other three predated the standard entirely: every image reference came from an environment file whose example left them all empty, so the documented deploy could not start, and one mounted its library from a Windows path. Every wave and every daily check walks public repositories only.

Rule

Whatever decides which repositories are judged must include the ones that are waiting to be published, or they fall behind silently for exactly as long as they wait. Publishing them is a migration, not a switch: history cleaned of the leaked file and scanned in full with no exemptions, the standard applied, and a release only after the public CI is green.

all four published: three as templates in the catalogue, released only on green CI; the fourth as a retired pointer, because its only server image was archived upstream

84

The red run in a private repository that nobody would read

Symptom

The site's daily job copies the fleet's numbers in and refuses a file that is not whole. The refusal is the whole point of the job. It went to a private repository's Actions tab, with Actions mail switched off.

Measured

The fleet's heartbeat watches that schedule for stopping, not for failing, and a run that refuses a bad file is a run that fired. So the one failure the job exists to catch would have kept yesterday's numbers in the HTML, correctly, and told nobody. Planted on a throwaway branch: the first failing run opened an issue, the second commented on it instead of opening another, and the next passing run closed it.

Rule

A job whose failure is the finding needs a reader for the failure, and in a private repository with mail off there is none by default. Open one issue, comment on repeats, close on recovery, and prove all three paths by planting them before merging.

heyvaldemar-com fleet-numbers.yml, 24 September 2026

85

A commit from a broken clone deleted a third of the operations repository, and nothing noticed for sixteen hours

Symptom

Mid-afternoon the operations repository's clone lost its .git/config; I recreated it with git init and a remote and carried on. The next commit, whose message spoke only of the release-signing workflow it added, also removed twenty tracked files: seven workflows among them, the lifecycle, scout, lab-sweep, secret-scan, upstream-review, Dependabot and rollout ones, the shared model-credential module, three rollout scripts, the backlog, the waivers, .gitignore.

Measured

The clone had lost the files along with its config, and git add -A staged the absences as deletions. Sixteen hours: the weekly jobs had not yet missed a run, so the heartbeat, which reads this repository's schedules from the checkout, saw a shorter list and no gap; the daily triage reported No module named 'anthropic_federation' on every upstream review and proceeded on the CI gate alone, as it is written to. The triage issue was the only place it showed, and I read it because the user asked whether everything was closed.

Rule

The set of watchers is a list that may only shrink on purpose. The heartbeat now carries a manifest of the workflows this repository is supposed to have and fails when one is missing from the checkout, or present and unlisted. And a commit from a clone that had to be repaired is read as a diff before it is pushed: a deletion in a commit that claims to add is the shape of this failure.

the twenty files restored as they were; scripts/expected-workflows.txt and the heartbeat check that reads it; 24 September 2026

Secrets · 3

86

A credential container has no shape to match

Symptom

A pre-commit secret scanner with patterns for age keys, tokens and webhooks let a private key through.

Measured

Traefik's acme.json carries its ACME account private key as base64 inside JSON. It passed every content pattern in the same commit where the scanner correctly rejected an ordinary DB_PASSWORD= line.

Rule

Refuse credential containers by name, whatever is inside them. And stop the list there: *.db is a secret on a configuration host and a fixture in a normal repository, and a rule that fires on both gets switched off.

sops-env-git v1.1.0, 32 assertions in both directions

87

Untracking a secret deletes it from the running host

Symptom

The obvious fix for a committed .env is git rm --cached.

Measured

On a host that deploys by git pull, that commit removes the file from the working tree. The stack fails on its next docker compose up, and the secrets it needed are gone with it.

Rule

Order matters: copy the file on the server first, then untrack, then rotate. And untracking does not remove it from history, so publishing the repository still exposes everything it ever held.

Documented; the four affected repositories stay private on purpose

88

Free secret scanning is a public-repository feature

Symptom

Private repositories on a personal account looked as protected as the public ones.

Measured

GitHub secret scanning is free on public repositories only; private ones need an enterprise plan. Eleven private repositories had no scanning at all.

Rule

Run gitleaks in CI over full history, pinned by digest, with false positives silenced by fingerprint rather than by path. A widened path exclusion is how a scanner comes to miss the true positives.

11 private repositories

Asking a model · 3

89

An empty answer, published as a report

Symptom

A weekly job asks a model which commits carry a transferable rule and opens an issue with the answer. The issue arrived with the table of commits and nothing said about any of them.

Measured

in=44175 out=4000, stop_reason max_tokens, and not one text block in the response. Extended thinking is on by default and produces no text until it finishes; raising the ceiling to 16,000 bought more thinking. Two sibling jobs had the same defect and got away with it on smaller inputs.

Rule

Check stop_reason before using the content, treat zero text blocks as a failure rather than an empty answer, and put the token counts in the failure message. Joining the text blocks of a response with no text blocks yields an empty string, which is printable, falsy, and writes to a file without complaint.

fleet-ops, in one shared helper with four assertions against a stand-in client

90

A chat button that can reach the Docker socket

Symptom

Moderation from a phone means an endpoint on the internet that can act on a host.

Measured

The split that holds: an edge container with no socket, no credentials, a read-only filesystem and an internal: true network, whose only verb is writing a file into a shared volume; a worker with the socket and no inbound path at all, not even on the edge's network. Each button carries a one-time nonce; using any button burns every nonce of that alert.

Rule

The capability, not the request, is what has to be small. There is no long-lived shared secret to leak when the nonce is the authorisation.

chatops-privilege-wall v1.0.0, 16 scenarios in CI

91

A rename, read as a change of user

Symptom

Dashy's notes for 4.7.7 said "Use UID/GID=1000 instead of node as default", and the upstream review called the bump NEEDS ATTENTION. From the notes alone that was right: a changed container user over a bind-mounted file is how an upgrade breaks quietly.

Measured

4.7.5 declares User=node and 4.7.7 declares User=1000:1000, and both resolve to uid 1000, gid 1000. In the same report ghost 6.64.0 to 6.65.0 differed in GHOST_VERSION and nothing else, and mc-backup 2026.9.1 to 2026.9.2 was identical in every field that changes behaviour.

Rule

A release note is a description; the image is the thing that runs. Give the reviewer both image configurations as the registry reports them, and where they disagree with the notes about the user, entrypoint, command, working directory, ports, volumes or health check, the configurations win. A registry that cannot be read says so, rather than reporting an image that did not change.

fleet-ops upstream review, 23 September 2026, with twenty-two assertions, one of them a live registry read

Upstream reality · 7

92

Three upstream facts a freshness check cannot see

Symptom

A pin can be perfectly current and still be wrong.

Measured

MinIO's community server was archived in April 2026, and the newest image is the one already pinned: green for ever. MongoDB 8.0 before 8.0.30 refuses to start on kernels 6.19 through 7.0.13 (TCMalloc rseq), while GitHub runners are on 6.17, so CI would pass a bump a modern host rejects. And a database pinned at a major on purpose never "lags", so a major reaching end of support sits in a template with every check green.

Rule

Freshness is the wrong instrument for anything deliberately pinned. Read the calendar instead: endoflife.date, the vendor's own compatibility endpoint, and an archived repository as a hard stop. Held pins need a written reason and a review date, and a lapsed date has to report as loudly as an unwaived finding.

fleet-lifecycle; outline-keycloak v2.0.0 replaced MinIO with Garage, proven by an S3 contract test through Traefik

93

A third of the reviews were reading repositories that do not exist

Symptom

Before every version bump, a model reads the upstream release notes against the template. Its verdicts kept coming back NEEDS ATTENTION because the notes could not be retrieved.

Measured

Every image the fleet pins was resolved the way the reviewer resolves it and the answer asked of GitHub: 34 of 100 named a repository that is not there. Each produced a verdict about the lookup wearing the clothes of a verdict about the upgrade. Found through beszel, whose agent ships from the hub's repository.

Rule

Accept a mapping only when the version the fleet currently pins exists as a tag in the repository named: mysql/mysql and three others looked plausible and failed that test. A wrong mapping is worse than none: it produces confident notes about somebody else's software. And "this publisher does not use GitHub" is a different answer from "not found".

fleet-ops, ten mappings and a seven-entry no-notes table; tests/test-review-sources.sh asks GitHub whether each name is real, and asserts its own lookup reports absence

94

A breaking marker whose notes never say what broke

Symptom

An upstream release arrived flagged feat! on a game server two people actually play on. The review refused it, correctly: a breaking marker with no explanation is not something to wave through.

Measured

The release notes carry the commit title and nothing else. The change does explain itself: a directory rename plus two lines of Dockerfile: base image steamrt/sniper/platform to steamrt/steamrt4/sdk, and the RCON forwarder simpleproxy to socat. Both inside the image; ports, variables and the compose interface unchanged.

Rule

When the notes do not explain the marker, read the diff rather than the adjective. The same release added two variables the template did not carry, and one of them decides whether GOTV relays player voice, a privacy question nobody should have to find in an upstream Dockerfile.

cs2-server and cs2-classic-server v1.2.0

95

A release always outpaces the things built on it

Symptom

A game server took a new upstream version the day it appeared, as its default configuration tells it to, and then exited during plugin resolution, in a loop, with its version already bumped and its backup already taken.

Measured

The plugins it pins had no build for that version yet. Nothing in the stack could have known: the answer lives in the plugin registry's API, not in any container. Asked directly, the registry answers exactly: 11, 7 and 1 builds for the version that works, 0, 0 and 0 for one that does not.

Rule

Where a version default says "latest", something has to ask whether the ecosystem has caught up, before the thing that converts data runs. Read the dependency list out of the file the server actually uses, so the check cannot drift from it, and make a registry that does not answer exit differently from one that answers no, because a timeout is not a verdict.

minecraft-server-docker-compose v1.6.0, in the daily verification and as a command to run by hand

96

The pin moves to where the version is not

Symptom

The same collision as the finding above, approached from the other side. A watcher offers the newest plugin build for a project, a one-line command applies it to every backend, and the backends are recreated.

Measured

The watcher's query filters by loader and not by game version, so the build it offers can target a release none of the servers run. The registry answers exactly: one version of the same plugin lists 26.2 and not 26.3, the next lists both. A pin the running version cannot load does not fail where it is written. It fails at container start, in ten backends that have already been recreated.

Rule

A dependency pin and a platform version collide from both directions, and refusing one does not refuse the other. Ask before writing the pin, ask for every version actually running rather than any of them, and let a registry that does not answer exit differently from one that answers no.

open for review on the host; measured against the live registry, including the case where the registry is unreachable

97

The generator's own upload refuses a release that targets a commit

Symptom

Signed releases rolled out to 97 repositories: 315 backfill runs, 279 green. The 36 that failed were in 14 repositories and always the same tags, the archive and signature already attached, the provenance not.

Measured

The SLSA generator attaches its provenance by editing the release, and GitHub answers that edit with Resource not accessible by integration for the workflow token when the release's target_commitish is a commit rather than a branch. Every release here cut from a verified commit targets that commit, on purpose. The step that only adds an asset had never been refused.

Rule

A reusable workflow's convenience path is an API call you did not choose. When it fails for a subset, diff the subset's metadata against the rest before reading the workflow: the difference was one field. Attach the file with the call that only adds, and let the generator do the one thing it is for.

release-assets.yml across the fleet: the provenance is downloaded from the run and attached with gh release upload; the 36 releases re-run green

98

The image a migration relied on stopped existing, twice

Symptom

The Outline template moved its object store from MinIO to Garage and ships a script that copies an old bucket across, plus a CI step that fills a real MinIO and proves the copy. MinIO had already removed minio/minio from Docker Hub; the script and the step were pointed at quay.io. On 25 September the digest refresh's verification died on unauthorized: quay.io had stopped answering anonymous pulls of that image too, and Docker Hub's copy has no tags left.

Measured

Checked from outside CI: a fresh anonymous token from quay.io gets 401 on every manifest of minio/minio, and hub.docker.com lists zero tags. The migration script starts a throwaway MinIO to read the old volume, so a user on a fresh machine had no way to run it either. Bitnami's frozen archive, bitnamilegacy/minio, still carries the last public build; tested here against a volume before it was pinned.

Rule

A test that depends on a third party's image is a test with an expiry date nobody knows. A vendor that has removed an image once will do it again; when the migration path depends on it, pin the last build that exists somewhere you do not control by digest, say where it came from and why, and leave the operator a variable to point at the image they already have.

outline-keycloak v2.1.1: the script and the CI step run the archived build, mounted where it keeps its data, with OUTLINE_MINIO_IMAGE_TAG and OUTLINE_MINIO_DATA_DIR as the way out; 25 September 2026