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]>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user