Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .agents/specs/container-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ the package name.
| `cpu` | `:<version>-cpu` | `:latest-cpu`, `:latest` | stable after the baseline-tier gate |
| `rocm` | — | — | blocked; no image is published |

Each lane also publishes a moving `:main-<lane>`. That is a change from the
original design, which published on tags only: `main` produced no image at all,
so anyone wanting to try the tree had to build it. Main images are a
convenience and carry no support claim -- they move, they never touch `:latest*`
or a version tag, and `promote` stays release-only so they cannot.

They are built when container INFRASTRUCTURE changes (`docker/**`, the matrix,
the validator, the build scripts, the workflow) and nightly otherwise. `main`
takes dozens of pushes a day and three lanes on two architectures per push is
prohibitive; a nightly floor bounds how stale a main image can be while
container changes still rebuild immediately.

Every lane is a `linux/amd64` + `linux/arm64` manifest list whose members are
built on native runners. aarch64 is first-class rather than an afterthought
because the project's own gate hardware — GB10 (`sm_121a`), Thor (`sm_110`),
Expand Down Expand Up @@ -544,7 +556,10 @@ paths. That is the distinction `/health` cannot make, and the GB10 result did
not make either.

**Scope.** Orin (`sm_87`) only. Thor (`sm_110`) has never been probed and
inherits nothing from this.
inherits nothing from this -- the node was unavailable, and it is owed a run
when it returns. Orin makes the Tegra family plausible for Thor, not proven:
Thor is a different SoC on a newer L4T, which is exactly the kind of
assumption this row has already been wrong about once.

### Pull-request scope, and why it is not a hole

Expand Down
53 changes: 47 additions & 6 deletions .github/workflows/containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ name: containers
on:
push:
tags: ['v*']
# main publishes moving :main-<lane> images. Only container INFRASTRUCTURE
# paths trigger it: main takes dozens of pushes a day and three lanes on two
# architectures each time is prohibitive, so product changes ride the
# nightly below and container changes rebuild immediately.
branches: [main]
paths:
- 'docker/**'
- 'release/container-matrix.json'
- 'scripts/validate-container-image.py'
- 'scripts/container_tags.py'
- 'scripts/build-cpu-release.sh'
- 'scripts/build-linux-accelerator-release.sh'
- '.github/workflows/containers.yml'
schedule:
# Nightly, so a main image is never more than a day behind the tree.
- cron: '0 4 * * *'
pull_request:
workflow_dispatch:
inputs:
Expand All @@ -46,6 +62,8 @@ jobs:
outputs:
version: ${{ steps.plan.outputs.version }}
is_release: ${{ steps.plan.outputs.is_release }}
is_main: ${{ steps.plan.outputs.is_main }}
publishes: ${{ steps.plan.outputs.publishes }}
verify_matrix: ${{ steps.matrix.outputs.verify }}
publish_matrix: ${{ steps.matrix.outputs.publish }}
steps:
Expand All @@ -63,6 +81,20 @@ jobs:
else
echo "is_release=false" >> "$GITHUB_OUTPUT"
fi
# A main publish is a push to main or the nightly -- never a pull
# request, which has no credentials and must not reach a registry.
is_main=false
if [ "${GITHUB_REF_TYPE}" != "tag" ] \
&& [ "${GITHUB_REF_NAME}" = "main" ] \
&& [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then
is_main=true
fi
echo "is_main=${is_main}" >> "$GITHUB_OUTPUT"
if [ "${is_main}" = "true" ] || [ "${GITHUB_REF_TYPE}" = "tag" ]; then
echo "publishes=true" >> "$GITHUB_OUTPUT"
else
echo "publishes=false" >> "$GITHUB_OUTPUT"
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Gate the container matrix against the Dockerfile
run: python3 scripts/check-container-matrix.py
Expand Down Expand Up @@ -122,7 +154,7 @@ jobs:

publish:
needs: [plan, verify]
if: needs.plan.outputs.is_release == 'true'
if: needs.plan.outputs.publishes == 'true'
permissions:
contents: read
packages: write
Expand All @@ -140,6 +172,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Refuse to overwrite an existing immutable tag
# Version tags only. :main-<lane> moves by design and is skipped here;
# what must never happen is a republished :<version>-<lane>.
if: needs.plan.outputs.is_release == 'true'
run: |
set -euo pipefail
tag="${REGISTRY_PACKAGE}:${{ needs.plan.outputs.version }}-${{ matrix.lane }}"
Expand Down Expand Up @@ -195,7 +230,7 @@ jobs:

manifest:
needs: [plan, publish]
if: needs.plan.outputs.is_release == 'true'
if: needs.plan.outputs.publishes == 'true'
permissions:
contents: read
packages: write
Expand Down Expand Up @@ -225,13 +260,18 @@ jobs:
refs="${refs} ${REGISTRY_PACKAGE}@$(cat "$file")"
done
test -n "${refs}"
docker buildx imagetools create \
--tag "${REGISTRY_PACKAGE}:${version}-${lane}" ${refs}
if [ "${{ needs.plan.outputs.is_release }}" = "true" ]; then
tag="${REGISTRY_PACKAGE}:${version}-${lane}"
else
# A main image is a moving convenience tag, never a version.
tag="${REGISTRY_PACKAGE}:main-${lane}"
fi
docker buildx imagetools create --tag "${tag}" ${refs}
done

attest:
needs: [plan, manifest]
if: needs.plan.outputs.is_release == 'true'
if: needs.plan.outputs.publishes == 'true'
permissions:
contents: read
id-token: write
Expand All @@ -244,9 +284,10 @@ jobs:
run: |
set -euo pipefail
version="${{ needs.plan.outputs.version }}"
if [ "${{ needs.plan.outputs.is_release }}" = "true" ]; then prefix="${version}-"; else prefix="main-"; fi
for lane in cpu vulkan cuda; do
digest=$(docker buildx imagetools inspect \
"${REGISTRY_PACKAGE}:${version}-${lane}" --format '{{json .Manifest.Digest}}' | tr -d '"')
"${REGISTRY_PACKAGE}:${prefix}${lane}" --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "${lane}=${digest}" >> "$GITHUB_OUTPUT"
done
- uses: actions/attest-build-provenance@v2
Expand Down
51 changes: 34 additions & 17 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,11 +523,15 @@ dependencies.

## Container images

Published to one GHCR package with the lane in the tag:
`ghcr.io/mudler/vllm.cpp:<version>-cuda`, `-vulkan`, `-cpu`, plus the moving
`:latest-<lane>`. The bare `:latest` is the **cpu** lane, so pulling it on a
machine with no accelerator gets a working server rather than a library-load
failure. Every lane is a `linux/amd64` + `linux/arm64` manifest.
Published to one GHCR package with the lane in the tag. Every lane is a
`linux/amd64` + `linux/arm64` manifest, so the same tag works on both.

| tag | what it is |
|---|---|
| `:<version>-cuda` / `-vulkan` / `-cpu` | **immutable.** Never republished |
| `:latest-cuda` / `-vulkan` / `-cpu` | moves to the newest **release** |
| `:latest` | the **cpu** lane, so pulling it on a machine with no accelerator gets a working server rather than a library-load failure |
| `:main-cuda` / `-vulkan` / `-cpu` | moves with **main**: rebuilt when container infrastructure changes and nightly otherwise. Convenience, not a release — no support claim |

The entrypoint is `vllm-server`, so flags go straight after the image name and
the server keeps its own default of `0.0.0.0:8000`:
Expand Down Expand Up @@ -555,27 +559,40 @@ weights under `/models` must be READABLE by it. A model file with mode `0600`
owned by another uid fails as `safetensors: cannot open file`, which reads like
a corrupt checkpoint rather than a permissions problem.

### On Jetson (Tegra/L4T)
### Picking the right flags for your GPU

The cuda image is one SBSA build and it runs on Jetson too -- verified on AGX
Orin (`sm_87`, L4T R36.4.3) -- but Tegra needs a **different invocation**:
The two NVIDIA families need **different** invocations, and this is verified on
both rather than inferred:

| host | verified on | flags |
|---|---|---|
| SBSA / datacenter arm64, x86_64 | GB10 `sm_121a` | `--gpus all` |
| Jetson / Tegra (L4T) | AGX Orin `sm_87`, L4T R36.4.3 | `--runtime nvidia --gpus all` |

On Jetson, `--gpus all` **alone is refused** ("invoking the NVIDIA Container
Runtime Hook directly ... is not supported"), and `--runtime nvidia` **alone**
starts a container with no driver that dies on `libcuda.so.1: cannot open
shared object file` — which looks like a broken image rather than a missing
flag. Use both:

```sh
docker run --rm --runtime nvidia --gpus all -p 8000:8000 \
-v /path/to/models:/models:ro \
ghcr.io/mudler/vllm.cpp:latest-cuda \
--model /models/your-model
--model /models/Qwen3-0.6B
```

`--gpus all` on its own is refused there ("invoking the NVIDIA Container Runtime
Hook directly ... is not supported"), and `--runtime nvidia` on its own starts a
container with no driver, which dies on `libcuda.so.1: cannot open shared object
file`. Both flags together are what works. `ffmpeg` is installed in every lane, so `/v1/videos` works out of the box —
a deliberate difference from the tarballs, which never vendor it because they
are extracted onto a host that already has a `PATH`.
That exact recipe was run on an AGX Orin with `Qwen/Qwen3-0.6B`: the server
serves `/v1/completions` and `tegrastats` shows `GR3D_FREQ` at 95-97% during
generation, so decode is on the GPU.

### If the server exits at startup

macOS Metal and MLX have no image and never will: there is no macOS container
runtime and no Metal passthrough. ROCm has no image until its backend compiles.
| symptom | cause |
|---|---|
| `safetensors: cannot open file` | the weights are not readable by **uid 1000**. The container runs as uid 1000; a `0600` model owned by another user fails here and looks like a corrupt checkpoint |
| `libcuda.so.1: cannot open shared object file` | no driver in the container — on Jetson, add `--gpus all` alongside `--runtime nvidia` |
| `--model <dir> is required` | the server takes flags directly; everything after the image name goes to `vllm-server` |

### Building and validating an image locally

Expand Down
4 changes: 4 additions & 0 deletions release/container-matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
],
"channel": "stable",
"id": "cpu",
"main_tag": "main-cpu",
"moving_tags": [
"latest-cpu",
"latest"
Expand All @@ -53,6 +54,7 @@
],
"channel": "preview",
"id": "vulkan",
"main_tag": "main-vulkan",
"moving_tags": [
"latest-vulkan"
],
Expand All @@ -75,6 +77,7 @@
],
"channel": "preview",
"id": "cuda",
"main_tag": "main-cuda",
"moving_tags": [
"latest-cuda"
],
Expand All @@ -95,6 +98,7 @@
],
"package": "ghcr.io/mudler/vllm.cpp",
"retention": {
"main_tags": "overwritten on every main publish; not immutable, no support claim",
"moving_tags": "overwritten on every release",
"untagged_digests_days": 30,
"version_tags": "maintainer-deletion-only"
Expand Down
39 changes: 34 additions & 5 deletions scripts/check-container-workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@
# Jobs that may hold registry write, and the single job that may hold OIDC.
REGISTRY_WRITE_JOBS = frozenset({"publish", "manifest", "promote"})
OIDC_JOBS = frozenset({"attest"})
# Everything downstream of verification only runs for a real release tag.
TAG_GATED_JOBS = ("publish", "manifest", "attest", "promote")
# `promote` moves :latest* and is RELEASE-ONLY. publish/manifest/attest also run
# for a main publish, which ships moving :main-<lane> tags -- so they are gated
# on `publishes`, which is true for a tag or main but never for a pull request.
RELEASE_ONLY_JOBS = ("promote",)
PUBLISH_JOBS = ("publish", "manifest", "attest")
TAG_GATED_JOBS = PUBLISH_JOBS + RELEASE_ONLY_JOBS
RELEASE_GUARD = "if: needs.plan.outputs.is_release == 'true'"
PUBLISH_GUARD = "if: needs.plan.outputs.publishes == 'true'"


def job_names(text: str) -> list[str]:
Expand Down Expand Up @@ -113,12 +118,36 @@ def validate(text: str) -> list[str]:
if "validate-container-image.py" not in verify_block:
errors.append("the verify job must run scripts/validate-container-image.py")

for name in TAG_GATED_JOBS:
for name in RELEASE_ONLY_JOBS:
if RELEASE_GUARD not in blocks[name]:
errors.append(
f"job {name!r} must be gated on {RELEASE_GUARD!r}: nothing publishes "
"between tags"
f"job {name!r} must be gated on {RELEASE_GUARD!r}: :latest follows a "
"RELEASE, and a main publish must never move it"
)
for name in PUBLISH_JOBS:
if PUBLISH_GUARD not in blocks[name]:
errors.append(
f"job {name!r} must be gated on {PUBLISH_GUARD!r}: it publishes, so a "
"pull request must not reach it"
)

# A main publish must not be able to write a version tag or :latest.
plan_text = blocks["plan"]
if 'GITHUB_EVENT_NAME}" != "pull_request"' not in plan_text:
errors.append(
"the plan job must exclude pull_request from is_main: a fork PR has no "
"credentials and must never be classified as a publish"
)
manifest = blocks["manifest"]
if "main-${lane}" not in manifest:
errors.append(
"the manifest job must tag a main publish :main-<lane>, never a version"
)
promote_block = blocks["promote"]
if "main-" in promote_block:
errors.append(
"the promote job must not touch main tags; it moves :latest* only"
)

publish = blocks["publish"]
immutable_guard = step_order(publish, "already exists; version tags are immutable")
Expand Down
23 changes: 23 additions & 0 deletions scripts/container_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ def moving_pairs(matrix: dict, version: str) -> list[tuple[str, str]]:
return pairs


def main_pairs(matrix: dict) -> list[tuple[str, str]]:
"""`<immutable source> <main tag>` pairs for a publish from main.

Main images are a convenience, not a release: they move, they carry no
support claim, and they must never touch `latest*` or a version tag.
"""
package = matrix["package"]
return [
(f"{package}:{lane['id']}", f"{package}:{lane['main_tag']}")
for lane in matrix["lanes"]
]


def main_tags(matrix: dict) -> list[str]:
package = matrix["package"]
return [f"{package}:{lane['main_tag']}" for lane in matrix["lanes"]]


def lane_ids(matrix: dict) -> list[str]:
return [lane["id"] for lane in matrix["lanes"]]

Expand Down Expand Up @@ -86,6 +104,7 @@ def main() -> int:
view.add_argument("--moving", action="store_true")
view.add_argument("--lanes", action="store_true")
view.add_argument("--build-matrix", action="store_true")
view.add_argument("--main-tags", action="store_true")
args = parser.parse_args()

matrix = json.loads(args.matrix.read_text(encoding="utf-8"))
Expand All @@ -94,6 +113,10 @@ def main() -> int:
print("\n".join(lane_ids(matrix)))
return 0

if args.main_tags:
print("\n".join(main_tags(matrix)))
return 0

if args.build_matrix:
entries = build_matrix(matrix, args.release)
if not entries:
Expand Down
Loading
Loading