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]>
157 lines
6.7 KiB
Markdown
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.
|