# 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/`. Anonymous pulls work, so consumers need no `imagePullSecret`. ``` images//Dockerfile the image images//README.md required — `make check` fails without it images//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 ```sh 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-` | yes | | tag `/vX.Y.Z` | `:vX.Y.Z`, `:vX.Y`, `:vX` | yes | | pull request | `:pr-` | 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//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](https://github.com/go-gitea/gitea/issues/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 ```sh 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: ```sh 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 the shell MCP server in the **`50W/kube-manifests`** repo. When its 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`. That pod runs with no Kubernetes credentials, HTTPS-only egress, a read-only root filesystem and all capabilities dropped, which is what `node-agent`'s smoke test mirrors. Do not add a pull secret there — the registry is public.