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]>
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+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 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— 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.
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.