Files
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

6.7 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 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.yamltoolsOverride.run_process.description, the tool description models actually read.
  • ai/kagent/remotemcpserver-shell.yamlspec.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.