AGENTS.md
Rama master · réplica de lectura del repositorio original.
# AGENTS.md
Guidance for AI agents and contributors working in this repository.
## What this repo is
`docker-spigot` builds and pushes the Docker image (`d3strukt0r/spigot`) that
wraps a Spigot server. It does **not** build the Spigot jars — those are built and
released by a separate repo,
[`Team-MaRo/spigot-build`](https://github.com/Team-MaRo/spigot-build) (the
jar-building used to live here). This repo **consumes** the finished jar.
There is no app source code — this repo is **CI/CD + a Docker entrypoint**. The
"architecture" is the workflows and the scripts they call.
## Big picture
- **The jar is a hash-pinned flake input.** `flake.nix` takes `spigot-build` as an
input and bakes `spigot-build.legacyPackages.<sys>.spigotJar."<ver>"` (a pinned
`fetchurl`, **not** an impure path) into the image at `/opt/spigot.jar`. The
version is chosen by **attribute** (`.#"<ver>"`), not an env var. New jar
versions / rebuilt hashes arrive via `nix flake update spigot-build` (a
Dependabot `nix` bump); the `push` trigger then rebuilds the images.
- **The image is built with Nix** (`flake.nix`, `dockerTools.streamLayeredImage`),
not a Dockerfile, with the **version-correct JRE** (the major comes from
`spigot-build.lib.jdkMajorFor`). Built per-arch on native runners
(`streamLayeredImage` is single-arch) and stitched into a multi-arch manifest,
then published with an SBOM, cosign signatures and SLSA provenance.
- **`--impure` is only for OCI labels.** `DOCKER_LABELS_JSON` (CI metadata) is the
single impure input; the jar and version are pure, so a plain
`nix build .#"<ver>"` works (just with empty labels).
- **The image matrix comes from `.#imageMatrix`** — `[{spigot, java}]` derived from
the pinned `spigot-build`'s released versions, so it matches exactly what can be
built.
### Key files
| Path | Role |
|---|---|
| `.github/workflows/docker.yml` | `matrix` (versions × JDK from `.#imageMatrix`; `workflow_dispatch` selector `latest`/`all`/`missing`/specific/comma-list (e.g. `1.17,1.17.1`), default `latest`, `+newest_n` — `missing` diffs against the tags already on Docker Hub via `dockerhub-tags.sh`; the **daily** schedule rebuilds the 2 newest, and a **push that changes `flake.lock`** builds everything `missing`) → `docker-build` (Nix image per version × arch via `nix build .#"<ver>"` + SBOM) → `docker-manifest` (multi-arch manifest, push, cosign sign/attest) → `docker-attest` (SLSA provenance) → `tag` (one git tag per built version on the build commit, force-moved on rebuild — provenance, not releases). **No jar building** — that's spigot-build. |
| `.github/workflows/refresh-jars.yml` | The **fresh-jar coupling**. spigot-build `repository_dispatch`es this (`jars-published`) right after it publishes a release; it runs `nix flake update spigot-build` and, if `flake.lock` changed, commits + pushes the bump **with `GH_PAT`** (so the push triggers `docker.yml` — `GITHUB_TOKEN` pushes don't). Single source of truth = the committed `flake.lock`, so the jar build is always finished before the image build. Same effect as a Dependabot `nix flake update`, just on-demand + same-day. |
| `.github/workflows/check-outdated.yml` | Daily watchdog over the newest released version's **image**: pulls + boots it (StopOnStart plugin, compiled against the `spigot-api.jar` release asset) and, on failure, routes by cause — `broken`/`missing` → rebuild the image (`docker.yml`); `outdated` (Spigot's stale-jar banner, not an image fault) → cross-repo dispatch spigot-build's `build.yml` (needs `GH_PAT`) to rebuild the jar, which then flows back via `refresh-jars.yml`. Newest version from `.#imageMatrix`. Also the keepalive (re-arms the schedules). |
| `.github/check-outdated/StopOnStart.java` + `plugin.yml` | Tiny Bukkit plugin: `onEnable()` → `getServer().shutdown()`. Used by the image watchdog to cleanly stop a server right after it boots. |
| `flake.nix` | Builds the OCI image (`dockerTools.streamLayeredImage`) per released version: `packages.<sys>."<ver>"` (+ `default`/`dockerImage` = newest). Jar = the pinned `spigot-build` fetch; runtime JRE from `JAVA_MAJOR` via `mkJre` (jlinked all-modules JRE, not the full JDK). Inputs: `nixpkgs`, `nix-utils`, `mc-server-init`, `spigot-build`. `--impure` only feeds `DOCKER_LABELS_JSON`. Also exposes `.#imageMatrix` (versions × JDK) for CI. |
| `src/entrypoint.sh` | Renders `server.properties`/`bukkit.yml`/`spigot.yml` from `MC__`/`BUKKIT__`/`SPIGOT__` env (via `yq`), applies the EULA gate + `BUNGEECORD` shortcut + `server-ip=0.0.0.0`, builds the JVM command (memory + a flags preset + `JVM_OPTS`), then `exec mc-server-init … -- java …` (PID 1 — PTY console + graceful stop). Packaged with `writeShellApplication`. |
| `mc-server-init` (flake input → `github:Team-MaRo/mc-server-init`) | Our first-party PID-1 init (Rust; deps `nix` + `clap`), in its own repo. Runs the server on a **PTY** (so JLine keeps the `>` prompt), forwards the container stdin **and** `/tmp/console-in` into the console, and turns SIGTERM/SIGINT/typed-Ctrl+C into a clean `stop` (SIGKILL after `--stop-timeout`). Replaced itzg's `mc-server-runner` (which piped stdin → no prompt). `flake.nix` binds `mcServerInit = mc-server-init.packages.<sys>.default`. Bump with `nix flake update mc-server-init`. |
| `spigot-build` (flake input → `github:Team-MaRo/spigot-build`) | Builds + publishes the Spigot/CraftBukkit jars and exposes them as hash-pinned fetches. The image consumes `legacyPackages.<sys>.spigotJar."<ver>"`; the per-version JDK comes from `lib.jdkMajorFor`; the version list from `lib.versions`. Bump with `nix flake update spigot-build`. |
### JDK-per-version mapping
The runtime needs a specific JDK per Minecraft version. The version→major **rule
lives in spigot-build** (`lib.jdkMajorFor`, mirroring its `matrix.ts`
`JDK_BOUNDARIES`); docker-spigot reads it from the input and maps the major to a
JRE (`jdkForPkgs` in `flake.nix`). **Major 16 is the exception**: nixpkgs ships
no JDK 16 and Spigot 1.17/1.17.1 reject Java 17+ at boot (*"Only up to Java 16 is
supported"*), so the flake fetches the Adoptium **Temurin 16** JDK per-arch
(`temurin16Jdk`, autoPatchelf'd — a build-time tool), then jlinks an
ALL-MODULE-PATH **JRE** from it (`temurin16Jre`, re-patched + store-path-scrubbed,
like the nixpkgs JDKs) instead of a nixpkgs JRE:
| Spigot / Minecraft version | JDK |
|---|---|
| ≤ 1.16.5 | 8 |
| 1.17 – 1.17.1 | 16 |
| 1.18 – 1.20.4 | 17 |
| 1.20.5 – 1.21.11 | 21 |
| ≥ 26.1 (year-based) | 25 |
Minecraft switched to **year-based versioning after `1.21.11`** (next release is
`26.1`), which is why the newest versions need JDK 25.
## Commands
```bash
# The image build matrix CI uses (versions × JDK, from the pinned spigot-build):
nix eval --json .#imageMatrix
# Build the image for a version locally (needs Nix). The jar is the pinned
# spigot-build fetch; --impure only feeds labels. Quote the dotted version.
nix build --impure '.#"26.1.2"' # or .#dockerImage / .#default for the newest
docker load < result # then: docker run -e EULA=true -p 25565:25565 -v $PWD/data:/srv/spigot d3strukt0r/spigot:26.1.2
# No local Nix (e.g. Windows/macOS host)? Build inside the nixos/nix container:
docker run --rm -v spigot-nix:/nix -v "$PWD:/work" -w /work nixos/nix \
sh -c "nix --extra-experimental-features 'nix-command flakes' build --impure '.#\"26.1.2\"' && cp -fL result /work/image.tar"
docker load -i image.tar
# Gotcha: flake.nix / scripts MUST be LF — CRLF makes Nix's inline `''…''` build
# scripts fail with `$'\r': command not found`. Enforced by .gitattributes
# (eol=lf) + .vscode files.eol; core.autocrlf=true alone would reintroduce CRLF.
# Test the image against LOCAL checkouts of the init and/or the jar repo (before
# pushing changes to them): override the inputs with their paths.
docker run --rm -v spigot-nix:/nix -v "$PWD:/work" \
-v "$PWD/../mc-server-init:/mc-init" -v "$PWD/../spigot-build:/spigot-build" \
-w /work nixos/nix sh -c "nix --extra-experimental-features 'nix-command flakes' build \
--override-input mc-server-init path:/mc-init --override-input spigot-build path:/spigot-build \
'.#dockerImage' && cp -fL result /work/image.tar"
# Build the StopOnStart plugin locally. Compile against the `spigot-api.jar`
# release asset — the full `spigot.jar` is a bundler jar on modern versions (the
# Bukkit API lives inside META-INF/versions, not on the classpath), so `-cp
# spigot.jar` fails with "cannot find symbol JavaPlugin".
javac --release 8 -cp spigot-api.jar -d plugin-build .github/check-outdated/StopOnStart.java
cp .github/check-outdated/plugin.yml plugin-build/
jar cf StopOnStart.jar -C plugin-build .
# Run a server (or the check) locally — see README "Running a server without Docker".
```
There is no test suite; validate by building an image (`nix build --impure
'.#"<ver>"'`) and by triggering the workflows via `workflow_dispatch`.
## Conventions & gotchas
- **Every workflow job and every step has a `name:`.** Keep it that way.
- **Actions are pinned to major versions** (`@v6`, `@v5`, …); dependabot tracks
minors. When adding an action, use the current major.
- **Jars come from `spigot-build`** (a flake input), consumed as a hash-pinned
fetch — this repo never runs BuildTools. To make a new version buildable here,
release it in spigot-build, then `nix flake update spigot-build`.
- **`allow-nether` and `allow-end` are `bukkit.yml` settings** in modern Spigot —
`allow-nether` in `server.properties` is the old, ignored location.
- **The "outdated" banner prints *before* the EULA gate**, so the image watchdog
can detect it without accepting the EULA (the server then self-exits at the gate).
- **The image ships `/etc/os-release` + `/etc/lsb-release`** (written in
`extraCommands`). The Nix minimal image has neither, and the JVM (oshi/JNA,
crash reports) logs *"File not found or not readable: /etc/os-release"* on every
boot without them.
- **`yq` is the Go (mikefarah) `yq` everywhere now** — both the workflows and the
entrypoint. It edits `server.properties` too (`-p props -o props`), which is a
Java `.properties` file (not TOML). No Python in the image anymore.
- **Image is Nix-built and version-aware.** The old `FROM openjdk:8-alpine`
limitation (couldn't run ≥ 1.17) is gone: the flake selects the runtime from
`JAVA_MAJOR`, so every version runs. Build locally with, e.g.,
`nix build --impure '.#"26.1.2"'`.
- **Ships a JRE, not the full JDK** (`mkJre` in `flake.nix`). A server needs no
JDK tools, so Java 9+ uses a `jre_minimal` jlink runtime (kept at
`ALL-MODULE-PATH` — every module, so any plugin works — only `jmods/`/dev
tools/debug dropped); Java 8 ships `jdk8_headless` as-is (no jlink pre-Java 9),
and Java 16 jlinks its JRE from the fetched **Temurin 16** JDK — same
ALL-MODULE-PATH runtime, but with a second autoPatchelf pass over the jlink
output (the jmod binaries carry Adoptium's interpreter/RPATH) — see the
JDK-per-version note above.
Cut the 26.1.2 image from ~1.73 GB to ~750 MB. Two non-obvious gotchas:
(1) `jre_minimal.override` must set **both** `jdk` *and* `jdkOnBuild` to the
same JDK, else jlink uses an older JDK and can't read newer jmods ("Unsupported
major.minor version"); (2) jlink bakes the source JDK's store path into
`lib/modules`, dragging the full JDK back into the closure — `mkJre` scrubs it
with `remove-references-to` and asserts via `disallowedReferences`. The
headless source keeps `java.desktop`'s AWT (no X11), required for
`server-icon.png` / map rendering.
- **`flake.lock` must be committed.** Generate it on a Nix machine with
`nix flake lock`; CI builds against it.
### Container configuration (entrypoint contract)
- **Env → config:** split a var name on `__` into a key path; the first segment
selects the file (`MC`→server.properties, `BUKKIT`→bukkit.yml,
`SPIGOT`→spigot.yml); a single `_` in a segment becomes `-`. Properties values
are strings (`strenv`); YAML values are type-inferred (`env`). Re-applied every
start (env wins for managed keys; other on-disk state persists). Limitation:
a key segment with a literal `_` (e.g. a world named `world_nether` under
`world-settings`) can't be expressed — edit the mounted file.
- **Shortcuts:** `EULA` (gate; refuses to start unless `true`; unset == `false`),
`BUNGEECORD` (spans three files). Memory: `MEMORY`/`INIT_MEMORY`/`MAX_MEMORY` —
**no default**; if all unset, no `-Xms`/`-Xmx` is passed (JVM default heap).
Flags: `JVM_FLAGS_PRESET` (`aikars` default / `velocity` / `meowice` (Java 25+
only, falls back to aikars otherwise) / `none`) + `JVM_OPTS` (appended last,
wins) + `SERVER_ARGS`. All support the `_FILE` Docker-secret suffix.
- **Process model:** the entrypoint does `exec mc-server-init … -- java …`, so
**mc-server-init is PID 1** (our first-party Rust init, its own repo
`github:Team-MaRo/mc-server-init`, a flake input). It runs the
server on a **PTY**, so JLine keeps the interactive `>` prompt. On `docker stop`
(SIGTERM), `SIGINT`, or a typed Ctrl+C, it sends a real `stop` → graceful save
**with the visible `Saving players/worlds/chunks` logs** (the JVM shutdown-hook
path saved correctly too but Log4j swallowed those lines), `SIGKILL` only after
`--stop-timeout` (60 s). Exits in <1 s, code 0. The PTY means `docker logs` may
contain terminal escape codes. No in-container restart script — use Docker's
restart policy. A non-empty `CMD`/args override still replaces the launch
(handled before the mc-server-init exec). NB: Ctrl+C reliability depends on Docker
delivering the `0x03` byte/SIGINT; `docker stop` is the canonical clean stop.
- **Console (no RCON):** mc-server-init forwards **both** the container's stdin
(interactive `docker run -it` / `docker attach` typing at the `>` prompt) **and**
a named pipe at `/tmp/console-in` into the server's console. Inject with the
bundled `console` helper (`writeShellScriptBin` → `echo "$*" > /tmp/console-in`):
`docker exec <c> console <cmd>`. (This replaced first a pure-bash FIFO+`cat`
attempt that couldn't deliver interactive TTY input, then itzg's
`mc-server-runner` which worked but piped stdin so JLine dropped the `>`
prompt — the PTY in mc-server-init restores it.)
- **Persistence:** single `/srv/spigot` volume (server working dir; FHS location
for served data); jar stays in the image at `/opt/spigot.jar`. Runs as the
`nonroot` user, uid `65532`; a bind-mounted volume must be writable by it.
- **Resolved env for `exec`:** `docker exec` is a sibling of PID 1 and only sees
the *configured* env, not the entrypoint's computed defaults. So the entrypoint
writes every resolved value to `/etc/profile.d/spigot-env.sh` (the flake adds an
`/etc/profile` that sources `profile.d`, and makes that dir writable by 65532):
`docker exec -it <c> sh -l` (login shell) picks them up; a bare `sh` won't. The
no-setup alternative is `tr '\0' '\n' < /proc/1/environ`.
## Required secrets / vars
`GITHUB_TOKEN` (provided), `vars.DOCKERHUB_USERNAME` + `secrets.DOCKERHUB_TOKEN`
for pushes, and `vars.IMAGE_NAME` for the image reference. The docker jobs need
`id-token: write` / `attestations: write` / `packages: write` (cosign + SLSA);
these are set per-job in `docker.yml`. If the Docker Hub creds are absent, the
manifest/attest steps skip cleanly (build + SBOM still run).