8.3 KiB
AGENTS.md
Guidance for coding agents when working in this repository.
Overview
A base image registry. Each image is self-contained in its own directory under
images/ and is published to the public rmcguire namespace on Gitea at
gitea.libretechconsulting.com/rmcguire/<image>. Anonymous pulls work, so
consumers need no imagePullSecret.
images/<name>/Dockerfile the image
images/<name>/README.md required — `make check` fails without it
images/<name>/test.sh optional smoke test; must be executable to run
template/ skeleton to copy when adding an image
hack/ the three scripts CI and the Makefile share
Makefile single entry point for local work and CI
.gitea/workflows/ lint.yaml, build.yaml
Core principle: do not pin, rebuild instead
Base image tags, apk package versions and tool releases are deliberately
unpinned. Rebuilding is how upstream updates land; the published image tag is
what pins things for consumers. Do not add =version to apk add, and do not
pin FROM to a digest.
Corollary: prefer an Alpine package over an upstream download when one exists —
one install mechanism beats two. Only fetch from upstream when Alpine does not
package the tool at all (yq is the sole example today). Where a major version
must be held back, constrain the major only and still track latest within it.
Adding an image
cp -r template images/my-image
$EDITOR images/my-image/Dockerfile images/my-image/README.md images/my-image/test.sh
make build IMAGE=my-image # builds and smoke-tests
make check lint
No workflow changes are ever needed — hack/select-images.sh discovers any
directory under images/ containing a Dockerfile.
Dockerfile conventions, all visible in images/node-agent/Dockerfile:
- Keep the
ARG VERSION/REVISION/CREATED+LABELblock last, so changing build metadata does not invalidate the layers above it. # hadolint ignore=DL3018aboveapk add, since versions are unpinned on purpose. State the reason in a comment.- Set
SHELL ["/bin/bash", "-o", "pipefail", "-c"]before anyRUNcontaining a pipe (only after bash is installed). - For a tool fetched from upstream, run it in the same layer (
yq --version). That is the check that matters: it catches a truncated download or an HTML error page, which is the realistic failure mode.
Tagging and releases
Tagging is derived from the git ref by hack/build-images.sh, identically for
every image in a run:
| Ref | Tags | Published |
|---|---|---|
push to main |
:latest, :main-<sha> |
yes |
tag <image>/vX.Y.Z |
:vX.Y.Z, :vX.Y, :vX |
yes |
| pull request | :pr-<n> |
no |
| anything else / local | :dev |
no |
latest has exactly one owner — main — so a release tag and a main build can
never race to define it. Release tags are per image: git tag node-agent/v1.0.0
builds and publishes that image alone.
Smoke tests
images/<name>/test.sh takes an image ref and must exit non-zero on failure. Two
conventions worth keeping, both in images/node-agent/test.sh:
- Assert the deployment contract, not just tool presence. node-agent runs
the checks under
--user 1000:1000 --read-only --tmpfs /tmp --cap-drop ALL, mirroring the securityContext it is actually deployed with, so CI catches read-only or non-root breakage. - Do not
set -einside the container script. Use afailcounter so every check reports, then exit with it.
Gitea Actions gotchas
These cost real debugging time. All three are Gitea behaviours, not bugs in this repo, and all three will look fine locally.
Never bind-mount the workspace into a sibling container. The job itself runs
in a container and the checkout lives in a docker volume, not on the host. A
docker run -v "$PWD":/repo started through the host's docker socket resolves
that path against the host, where it does not exist — Docker silently creates
an empty directory, and the tool reports every file as missing. Stream files in
on stdin instead; hack/lint.sh does this for hadolint and shellcheck. Note
docker build and docker run without -v are unaffected, because the build
context is streamed over the API.
Do not use a dynamic fromJSON(needs...) matrix. Gitea resolves a job's
matrix when it parses the workflow, before needs outputs exist, so you get a
single job whose name is the raw uninterpolated expression. See
nameWithMatrix in Gitea's modules/actions/jobparser and
go-gitea/gitea#28207. That is
why build.yaml loops over images inside one job and emits ::group:: markers
per image instead.
Do not put ${{ }} in a job or step name:. Gitea does not interpolate
expression contexts in names — name: Log in to ${{ env.REGISTRY }} renders
literally. Give every step an explicit static name:, or it displays as the raw
run: command.
Verifying
make all # check + lint + build + smoke-test; what CI does
make help # all targets
To reproduce the CI container environment locally — worth it before touching
anything in hack/ or the workflows, since bind-mount and path assumptions only
break there:
docker volume create ci-sim
docker run --rm -v ci-sim:/w -v "$PWD":/src:ro alpine sh -c 'cp -a /src/. /w/'
docker run --rm -v ci-sim:/workspace/rmcguire/images \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/rmcguire/images -e GITHUB_ACTIONS=true \
--entrypoint bash gitea/runner-images:ubuntu-latest -c 'make check lint'
docker volume rm ci-sim
The runner is a local Docker container (gitea-gitea_runner-1), config at
~/work/docker/gitea/data/act_runner/config/config.yaml. Its cached
gitea/runner-images:ubuntu-latest can go stale; current action majors all
declare using: node24, so refresh it with
docker pull gitea/runner-images:ubuntu-latest if JS actions misbehave.
When bumping action versions, check the real latest major rather than copying from a sibling repo — several were a major behind.
Downstream consumers
node-agent backs three MCP servers in the 50W/kube-manifests repo:
shell— an arbitrary command runner. When this image's toolset changes, update all three of these or agents will not know what they can run:toolhive/mcpserver-shell.yaml— theWHAT'S IN THIS CONTAINERcomment block.toolhive/mcptoolconfig-shell.yaml—toolsOverride.run_process.description, the tool description models actually read.ai/kagent/remotemcpserver-shell.yaml—spec.description.
yamllint— a small stdio MCP server shipped as a ConfigMap (toolhive/cm-yamllint-mcp.yaml) that runs on this image and shells out toyamllintandkubectlto exposelint_yaml/lint_manifest. This is whyyamllintis in the apk list; it is the only reason that package is here.fetch-jq— another ConfigMap-shipped stdio MCP server (toolhive/cm-fetch-jq-mcp.yaml), exposingfetch_json_jq/fetch_json. It pipes a fetched HTTP response straight intojq's stdin (spawnSyncwith aninput:option, no temp file), so a model never has to scan a large raw JSON array by eye to find one value.jqwas already in the apk list before this consumer existed — nothing had to be added for it, unlikeyamllint.
Neither shell nor yamllint gets a pull secret — the registry is public. Same
for fetch-jq.
The three pods have very different privileges, so do not assume kubectl in
this image implies cluster access — that depends entirely on the consuming
pod's ServiceAccount:
shell |
yamllint |
fetch-jq |
|
|---|---|---|---|
| ServiceAccount | cluster-wide read (incl. pod logs) | no role bindings at all | none mounted |
| Egress | HTTPS + API server | API server only, no internet | HTTPS to whatever URL a caller passes — no API server |
kubectl can |
get/list/watch most kinds |
only fetch the OpenAPI schema | n/a — never invoked |
fetch-jq is the odd one out: it's the only one of the three with open internet
egress and the only one with zero Kubernetes access of any kind — see
toolhive/mcpserver-fetch-jq.yaml for why it mounts no ServiceAccount at all.
node-agent's smoke test mirrors the stricter shared parts of that contract —
non-root, read-only root filesystem, all capabilities dropped, /tmp a tmpfs.