From e8855a220a51fecbcdf1f199033f7476e4e92376 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 11 Aug 2026 20:40:57 +0000 Subject: [PATCH] feat(containers): publish :main- images, and document the flags that work FOLLOWING_AGENTS_PROTOCOL main produced NO image at all. The original design published on tags only, so anyone wanting to run the tree had to build it themselves -- a real gap, and the reason for this change. Each lane now also publishes a moving :main-. It is a convenience, not a release: it carries no support claim, and it must never touch :latest* or a version tag. That is enforced, not assumed -- promote (which moves :latest*) stays gated on is_release publish/manifest/attest gated on `publishes` = tag OR main, never PR the immutable-tag guard applies to version tags only; main moves plan refuses to classify a pull_request as main with a mutation case for each, including one asserting a main run cannot write a version tag and one asserting promote cannot touch main tags. WHEN it builds is a cost decision, stated because it is a trade: container INFRASTRUCTURE changes (docker/**, the matrix, the validator, the build scripts, the workflow) rebuild immediately, and everything else rides a nightly. main takes dozens of pushes a day and three lanes on two architectures per push is prohibitive; the nightly bounds staleness at a day. DOCS, written from what was actually run rather than from the design. The two NVIDIA families need different flags and the docs previously gave only one: SBSA / x86_64 --gpus all (verified GB10 sm_121a) Jetson / Tegra --runtime nvidia --gpus all (verified AGX Orin sm_87) plus a startup-failure table for the three ways this actually bit during bring-up: weights not readable by uid 1000 (reads as a corrupt checkpoint), libcuda.so.1 missing (a Jetson flag problem, not a broken image), and flags passed to the wrong place. Thor (sm_110) is recorded as owed, not assumed: the node was unavailable. Orin makes Thor plausible, not proven -- a different SoC on a newer L4T, which is the kind of assumption this row has already been wrong about once. Issue: #170 Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code] --- .agents/specs/container-images.md | 17 +++++- .github/workflows/containers.yml | 53 ++++++++++++++++--- docs/USAGE.md | 51 ++++++++++++------ release/container-matrix.json | 4 ++ scripts/check-container-workflow.py | 39 ++++++++++++-- scripts/container_tags.py | 23 ++++++++ .../scripts/test_check_container_workflow.py | 47 ++++++++++++++-- 7 files changed, 202 insertions(+), 32 deletions(-) diff --git a/.agents/specs/container-images.md b/.agents/specs/container-images.md index 028ce005f..6b7835fad 100644 --- a/.agents/specs/container-images.md +++ b/.agents/specs/container-images.md @@ -30,6 +30,18 @@ the package name. | `cpu` | `:-cpu` | `:latest-cpu`, `:latest` | stable after the baseline-tier gate | | `rocm` | — | — | blocked; no image is published | +Each lane also publishes a moving `:main-`. 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`), @@ -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 diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 7e6ed2c8d..821ddd9a1 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -21,6 +21,22 @@ name: containers on: push: tags: ['v*'] + # main publishes moving :main- 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: @@ -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: @@ -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 @@ -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 @@ -140,6 +172,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Refuse to overwrite an existing immutable tag + # Version tags only. :main- moves by design and is skipped here; + # what must never happen is a republished :-. + if: needs.plan.outputs.is_release == 'true' run: | set -euo pipefail tag="${REGISTRY_PACKAGE}:${{ needs.plan.outputs.version }}-${{ matrix.lane }}" @@ -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 @@ -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 @@ -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 diff --git a/docs/USAGE.md b/docs/USAGE.md index 22109f1e5..c602a3b16 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -523,11 +523,15 @@ dependencies. ## Container images -Published to one GHCR package with the lane in the tag: -`ghcr.io/mudler/vllm.cpp:-cuda`, `-vulkan`, `-cpu`, plus the moving -`:latest-`. 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 | +|---|---| +| `:-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`: @@ -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 is required` | the server takes flags directly; everything after the image name goes to `vllm-server` | ### Building and validating an image locally diff --git a/release/container-matrix.json b/release/container-matrix.json index e54513331..c7b68c7d9 100644 --- a/release/container-matrix.json +++ b/release/container-matrix.json @@ -28,6 +28,7 @@ ], "channel": "stable", "id": "cpu", + "main_tag": "main-cpu", "moving_tags": [ "latest-cpu", "latest" @@ -53,6 +54,7 @@ ], "channel": "preview", "id": "vulkan", + "main_tag": "main-vulkan", "moving_tags": [ "latest-vulkan" ], @@ -75,6 +77,7 @@ ], "channel": "preview", "id": "cuda", + "main_tag": "main-cuda", "moving_tags": [ "latest-cuda" ], @@ -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" diff --git a/scripts/check-container-workflow.py b/scripts/check-container-workflow.py index 2c58685c1..c330b8e8a 100755 --- a/scripts/check-container-workflow.py +++ b/scripts/check-container-workflow.py @@ -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- 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]: @@ -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-, 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") diff --git a/scripts/container_tags.py b/scripts/container_tags.py index aea8fe935..e8cec72c3 100755 --- a/scripts/container_tags.py +++ b/scripts/container_tags.py @@ -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]]: + """`
` 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"]] @@ -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")) @@ -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: diff --git a/tests/scripts/test_check_container_workflow.py b/tests/scripts/test_check_container_workflow.py index 66f6d1ca8..c28f46f8c 100644 --- a/tests/scripts/test_check_container_workflow.py +++ b/tests/scripts/test_check_container_workflow.py @@ -90,17 +90,17 @@ def test_a_publish_job_without_registry_write_is_rejected(self): class TagGateMutationTests(unittest.TestCase): def test_an_ungated_publish_job_is_rejected(self): text = SHIPPED.replace( - " publish:\n needs: [plan, verify]\n if: needs.plan.outputs.is_release == 'true'\n", + " publish:\n needs: [plan, verify]\n if: needs.plan.outputs.publishes == 'true'\n", " publish:\n needs: [plan, verify]\n", ) - assert_flags(self, text, "nothing publishes between tags") + assert_flags(self, text, "a pull request must not reach it") def test_an_ungated_promote_job_is_rejected(self): text = SHIPPED.replace( " promote:\n needs: [plan, attest]\n if: needs.plan.outputs.is_release == 'true'\n", " promote:\n needs: [plan, attest]\n", ) - assert_flags(self, text, "nothing publishes between tags") + assert_flags(self, text, ":latest follows a RELEASE") def test_dropping_the_tag_version_check_is_rejected(self): text = SHIPPED.replace('test "${GITHUB_REF_NAME}" = "v${version}"', "true") @@ -190,6 +190,47 @@ def test_a_missing_job_is_reported_before_anything_else(self): assert_flags(self, text, "missing the 'attest' job") +class MainPublishTests(unittest.TestCase): + """Main ships moving :main- images. What must stay impossible is a + main run writing a version tag or moving :latest, and a pull request + reaching a registry at all.""" + + def test_promote_stays_release_only(self): + promote = guard.job_block(SHIPPED, "promote") + text = SHIPPED.replace( + promote, + promote.replace( + "if: needs.plan.outputs.is_release == 'true'", + "if: needs.plan.outputs.publishes == 'true'", + ), + ) + assert_flags(self, text, ":latest follows a RELEASE") + + def test_publish_must_be_gated_on_publishes(self): + publish = guard.job_block(SHIPPED, "publish") + text = SHIPPED.replace( + publish, + publish.replace("if: needs.plan.outputs.publishes == 'true'\n", "", 1), + ) + assert_flags(self, text, "a pull request must not reach it") + + def test_a_pull_request_cannot_be_classified_as_a_main_publish(self): + text = SHIPPED.replace('GITHUB_EVENT_NAME}" != "pull_request"', 'true') + assert_flags(self, text, "must never be classified as a publish") + + def test_main_publish_must_not_write_a_version_tag(self): + manifest = guard.job_block(SHIPPED, "manifest") + text = SHIPPED.replace(manifest, manifest.replace("main-${lane}", "${version}-${lane}")) + assert_flags(self, text, "never a version") + + def test_promote_must_not_move_main_tags(self): + promote = guard.job_block(SHIPPED, "promote") + text = SHIPPED.replace( + promote, promote.replace("--moving", "--moving # main-cpu") + ) + assert_flags(self, text, "moves :latest* only") + + class BuildMatrixTests(unittest.TestCase): """The reduced PR matrix is a cost decision; it must not become a publish gap."""