Files
images/AGENTS.md
T
rmcguireandClaude Opus 5 b888f09eb1 Readable job names, latest tracks main, and an AGENTS.md
Job names in the Gitea UI rendered as the raw matrix expression
("${{ fromJSON(needs.select.outputs.images) }}"). Gitea resolves a job's matrix
when it PARSES the workflow, before `needs` outputs exist, so matrix.image was
interpolated against an unresolved matrix. There is no way to win with a dynamic
matrix: jobparser.nameWithMatrix interpolates a name containing "${{ }}", and
appends "(<values>)" to one that doesn't — either way the raw expression shows.
See go-gitea/gitea#28207.

So drop the matrix. build.yaml now runs one job, "Build changed images", that
loops over the selected images and emits ::group:: markers, giving a collapsible
section per image. Also: every step has an explicit static name, because Gitea
does not interpolate expressions in names either ("Log in to ${{ env.REGISTRY }}"
rendered literally).

Tag scheme, per review: `latest` now tracks main rather than the newest release,
and `edge` is gone — it's an Alpine/Traefik convention, not a broad standard, and
`main-<sha>` already covers "a specific commit". `latest` has exactly one owner
so a release tag and a main build can't race to define it. Release tags remain
immutable `:vX.Y.Z`/`:vX.Y`/`:vX` for pinning. This also means the ToolHive
manifest's `:latest` resolves as soon as this lands on main, with no release tag
needed first.

hack/docker-tags.sh folded into hack/build-images.sh, which is now the whole
pipeline — tag, build, smoke-test, push — shared by `make build` and CI. It uses
plain `docker build` instead of buildx, dropping setup-buildx-action: it loads
into the local store so the test runs pre-publish, and emits a plain manifest
with no attestations for Gitea's registry.

Verified in a simulated runner (repo in a docker volume, socket mounted, real
runner image): select + build + group markers, all four tag modes, and the
multi-image loop with a scratch second image.

AGENTS.md records the conventions and, importantly, the three Gitea gotchas that
all look fine locally: no bind-mounting the workspace into a sibling container,
no dynamic matrix, no expressions in names.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-09 12:05:23 -04:00

157 lines
6.7 KiB
Markdown

# 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
```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-<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](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.