Test The Stack
Question
All of my code-smell checks seem to be working great now, but my infrastructure-as-code still needs some work. It's not really working well and is hard for the agent to update as it gets more complicated. Any suggestions?
Solution
Yes! Automated application testing using our lifecycle operations calls.
Explanation
Everything so far checks that code is well-formed. Nothing so far
checks that apps/demo-app actually comes up, updates, and tears down
cleanly - that is what appctl-tester is for. It is a skill like any
other, but what makes it work is that appctl itself is portable:
appctl <verb> <app> --env <name> never changes shape, whichever app
<app> names, so one generic test runner can drive any of them the
same way.
Each app owns its own test cases, stored under its own test/cases/:
apps/demo-app/test/cases
├── 00-full-static-qa
│ └── fixtures
├── 01-full-functional-eval
└── 02-bad-tag-fails
Source: apps/demo-app/test/cases/
Three cases, three different shapes of "does this still work" - all
run the same way, by the same runner, because appctl gave them a
consistent surface to test against in the first place.
Static QA
00-full-static-qa lints, formats, and doc-checks just this app's own
shell surface - apps/appctl, apps/demo-app/libs/appctl-* - using
the exact same three wrapper scripts from two pages ago, just pointed
at this case's own throwaway fixtures/*.conf files instead of ci/'s
production ones. Scoped and checked in isolation: a lint failure
anywhere else in the repo never fails this app's own case, and this
case never widens to cover anything outside its own scope either.
Full Lifecycle
01-full-functional-eval drives the real lifecycle against a real
local docker engine: build, deploy, test, update to a second real
tag, test again, destroy. "Test" here means polling the actually
served page - matching both the greeting text and the image tag
baked into it - not just checking that a container exists and is
marked healthy. A tag can resolve, pull cleanly, and still serve the
wrong content; this case is the one that would notice.
Bad Tag
02-bad-tag-fails is the negative case: it builds and deploys
normally first, then rewrites the pin to a tag that cannot resolve and
expects appctl build itself to refuse it - the one place a new pin
is ever fetched, so that is where a bad tag has to fail. A pass here
means appctl correctly refused the bad tag, the same way
shell-lint's own tests prove it catches a bad script.
Checksum Proof
A real run is expensive - a real docker engine, real containers,
real waiting for healthy. So a run that already happened and passed
is trusted again, until something it depended on changes, via two
layers of committed proof. Per case, checksum.sha256 covers that
case's own test, rc, and fixtures/ - nothing elsewhere, so
editing the case invalidates only that case:
ef58854b... apps/demo-app/test/cases/01-full-functional-eval/docs/test.md
5feceb66... apps/demo-app/test/cases/01-full-functional-eval/rc
ec7d6249... apps/demo-app/test/cases/01-full-functional-eval/test
Source: apps/demo-app/test/cases/01-full-functional-eval/checksum.sha256
Per app, one committed log under test/logs/ covers everything
outside that app's own test/ - the shared substrate every case
runs against (apps/appctl, this app's own libs/) - plus the
environment it ran under, ending in a trailing RESULT: PASS:
# appctl-tester log - 2026-11-06T14:32:26Z - app=demo-app
# checksums (sha256sum -c compatible, paths relative to repo root)
7b080c96... .agents/skills/appctl-tester/scripts/appctl-tester
0a91d97e... apps/appctl
8e6dba02... apps/demo-app/compose.yml
... (one line per tracked file)
# environment
docker: Docker version 26.1.5
compose: Docker Compose version v2.27.0
kernel: Linux ... 7.2.11-zen1-1-zen ...
os: Alpine Linux unknown
# steps
2026-11-06T14:32:30Z pass 00-full-static-qa
2026-11-06T14:32:44Z pass 01-full-functional-eval
2026-11-06T14:32:51Z pass 02-bad-tag-fails
RESULT: PASS
Source (rotates, newest two kept): apps/demo-app/test/logs/
An app counts as verified only if both layers check out: every
tracked file in the log still matches its checksum and the log still
ends in RESULT: PASS, and every one of that app's own cases still
has a matching checksum.sha256. Either one being stale or missing
forces a full real run of every case for that app - never a partial
re-run of just the stale case, which keeps the pass/fail decision
binary per app instead of a web of per-case bookkeeping.
Commit Gate
appctl-tester is just one more entry in ci/scripts/, so the
pre-commit loop from the previous page calls it the exact same way it
calls everything else - --githook-precommit, unconditionally. The
scoping happens inside the script, same principle as every other
check: whichever app(s) have a staged file under apps/<name>/ are
in scope, checked against their own committed proof; an app with
nothing staged is left alone entirely. Checked, never run for real
from a commit hook - a stale app gets a real run right then, but only
because --githook-precommit itself forces verify-first, not because
a human remembered to ask for it.
CI Verify Only
In .gitlab-ci.yml, ailm-10-appctl-tester-job calls
bash ci/scripts/10-appctl-tester all - no --verify flag at all.
It does not need one:
# CI=true always means verify-only, regardless of which form was
# used: CI never attempts a real run, it only ever checks that
# someone already did, locally, and committed the proof.
if [[ "${CI:-}" == 'true' ]]; then
mode='verify'
fi
Source: .agents/skills/appctl-tester/scripts/appctl-tester
GitLab sets CI=true in every job automatically, and this line
overrides whatever mode the arguments alone would have selected -
even a CI job's own script: forgetting to pass --verify could
never accidentally trigger a real run. Stale or missing proof for any
app then fails the job outright (exit 3), naming exactly which
app's proof was not current, rather than silently attempting - and
likely failing differently - a real run with no engine or port to use.
appctl-tester --help lays out the same three ways to call it that
drive everything above:
usage: ./appctl-tester <app-name>|all [--verify]
./appctl-tester --githook-precommit
<app-name> run that one app's test cases
all run every app under apps/
--verify check each app's committed proof first;
run for real only if stale
--githook-precommit scope is whichever app(s) have a staged
file under apps/<name>/; none staged
means nothing to do
(no --verify) always runs for real, regardless of staleness.
CI=true always forces verify-only - CI never attempts a real run.
Source: .agents/skills/appctl-tester/scripts/appctl-tester
<app-name>targets one app by its directory name underapps/-demo-app, in every example on this page.alltargets every app found underapps/, for a single command that proves the whole repo's app surface still works.--verify(only after<app-name>/all) checks committed proof first, running for real only if it is stale - theChecksum Proofsection above is exactly what it checks.--githook-precommitis its own scope, not a modifier on the other two: whichever app(s) have a staged file, verify-first, always.- (no flags beyond
<app-name>/all) always runs for real, regardless of staleness - the deliberate "just run it" form, for manual, local use. CI=trueis checked automatically, not passed as a flag at all, and always wins over every other form above.