Files
images/AGENTS.md
T
rmcguire d39c2c8254
Build Images / Select images (push) Successful in 16s
Lint / Lint (push) Successful in 1m16s
Build Images / Build changed images (push) Successful in 2m49s
update agents md
2026-10-05 16:45:21 -04:00

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 + LABEL block last, so changing build metadata does not invalidate the layers above it.
  • # hadolint ignore=DL3018 above apk add, since versions are unpinned on purpose. State the reason in a comment.
  • Set SHELL ["/bin/bash", "-o", "pipefail", "-c"] before any RUN containing 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 -e inside the container script. Use a fail counter 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 — the WHAT'S IN THIS CONTAINER comment 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 to yamllint and kubectl to expose lint_yaml / lint_manifest. This is why yamllint is 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), exposing fetch_json_jq / fetch_json. It pipes a fetched HTTP response straight into jq's stdin (spawnSync with an input: option, no temp file), so a model never has to scan a large raw JSON array by eye to find one value. jq was already in the apk list before this consumer existed — nothing had to be added for it, unlike yamllint.

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.