From 5475fd98fff93b7bab66bf2ffd62b13232810aa2 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 04:40:49 -0500 Subject: [PATCH 01/10] ci(docker): dual-publish image to GHCR and Docker Hub Add root Dockerfile (Rust 1.97 + libhdf5, example binary) and docker.yml with PR verify-only vs main/tag dual push. Document pull paths in README. Part of #26 / LIM-996. Co-authored-by: Grok --- .dockerignore | 26 +++++++ .github/workflows/docker.yml | 130 +++++++++++++++++++++++++++++++++++ AGENTS.md | 2 + CHANGELOG.md | 5 ++ Dockerfile | 56 +++++++++++++++ README.md | 25 +++++++ 6 files changed, 244 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bacf4a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Keep Docker context small and free of secrets / build junk. +.git +.github +.target +target +**/target +fuzz/target +fuzz/corpus +fuzz/artifacts +.cursor +.devcontainer +*.md +!README.md +!CHANGELOG.md +!COMPATIBILITY.md +!TESTING.md +!LICENSE-* +AGENTS.md +CLAUDE.md +REVIEW.md +cubic.yaml +qodana.yaml +scripts +.env +.env.* +**/.DS_Store diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..616c246 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,130 @@ +# Docker build + dual push (Docker Hub + GitHub Container Registry). +# Third-party Actions pinned to immutable commit SHAs. +# Local Buildx only (no Docker Build Cloud). +# +# PR: verify build + run tests in builder stage (contents: read only). +# main / tag: publish to Hub + GHCR (packages: write only on publish job). +name: Docker + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + docker-verify: + name: docker verify + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log in to Docker Hub + # Same-repo human PRs may pull private base layers; Dependabot has no secrets. + if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'dependabot[bot]' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ vars.DOCKER_USER }} + password: ${{ secrets.DOCKER_PAT }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Set image tags + id: image + run: | + set -euo pipefail + SHA="${{ github.sha }}" + echo "runtime_tag=nir-rs:pr-${SHA}" >> "$GITHUB_OUTPUT" + echo "builder_tag=nir-rs:builder-pr-${SHA}" >> "$GITHUB_OUTPUT" + + - name: Build runtime image (load locally) + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: false + load: true + tags: ${{ steps.image.outputs.runtime_tag }} + + - name: Smoke runtime image + run: | + set -euo pipefail + docker run --rm "${{ steps.image.outputs.runtime_tag }}" rustc --version + docker run --rm "${{ steps.image.outputs.runtime_tag }}" \ + sh -c 'test -x /usr/local/bin/load_inspect_lif' + docker run --rm "${{ steps.image.outputs.runtime_tag }}" \ + sh -c 'pkg-config --exists hdf5' + + # Builder stage already runs `cargo test --all-features` during the image build. + - name: Build builder stage (exercises tests inside Dockerfile) + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: false + load: true + target: builder + tags: ${{ steps.image.outputs.builder_tag }} + + docker-publish: + name: docker publish + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Log in to Docker Hub + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ vars.DOCKER_USER }} + password: ${{ secrets.DOCKER_PAT }} + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Set image tags + id: image + run: | + set -euo pipefail + SHA="${{ github.sha }}" + VER=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') + if [ -z "${VER}" ] || ! printf '%s' "${VER}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Failed to extract semver X.Y.Z from Cargo.toml (got: ${VER:-empty})" + exit 1 + fi + HUB="${{ vars.DOCKER_USER }}/nir-rs" + GHCR="ghcr.io/limen-neural/nir-rs" + TAGS="${HUB}:${SHA},${HUB}:${VER},${HUB}:latest" + TAGS="${TAGS},${GHCR}:${SHA},${GHCR}:${VER},${GHCR}:latest" + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + echo "version=${VER}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: true + tags: ${{ steps.image.outputs.tags }} diff --git a/AGENTS.md b/AGENTS.md index 2845cb1..616a665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,8 @@ Prefer a prebuilt image when the host supports it (Rust 1.97 + `libhdf5-dev`): | Path | Consumer | |------|----------| +| [`Dockerfile`](Dockerfile) → `ghcr.io/limen-neural/nir-rs` + Docker Hub | Published toolchain image (GHCR + Hub) | +| [`.github/workflows/docker.yml`](.github/workflows/docker.yml) | PR verify; `main`/tag dual-publish | | [`.cursor/environment.json`](.cursor/environment.json) → [`.cursor/Dockerfile`](.cursor/Dockerfile) | Cursor cloud agents | | [`.devcontainer/devcontainer.json`](.devcontainer/devcontainer.json) | VS Code / Cursor Desktop | | [`scripts/agent-bootstrap.sh`](scripts/agent-bootstrap.sh) | cubic / Claude / other bare sandboxes | diff --git a/CHANGELOG.md b/CHANGELOG.md index b53027c..fbf3f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ for the **0.x** series as described under [Versioning](#versioning) below. ## [Unreleased] +### Added + +- Docker image + dual publish to **GHCR** (`ghcr.io/limen-neural/nir-rs`) and + **Docker Hub** (`$DOCKER_USER/nir-rs`); PR verify / main+tag push (#26). + ## [0.4.1] - 2026-08-12 First **crates.io** release of the 0.4 line (package name `nir-rs`). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3f64f1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 +# nir-rs — published image for GHCR + Docker Hub. +# +# Builder stage: compile/test with system libhdf5 (matches CI quality bar). +# Runtime stage: toolchain + libhdf5 for agents/consumers (not an SNN simulator). +# No Python in either stage (AGENTS.md). + +ARG RUST_IMAGE=rust:1.97-bookworm + +FROM ${RUST_IMAGE} AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + pkg-config \ + libhdf5-dev \ + build-essential \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . . + +RUN rustup component add clippy rustfmt \ + && cargo test --all-features \ + && cargo build --release --example load_inspect_lif --features hdf5 + +# --------------------------------------------------------------------------- +# Published image: Rust pin + libhdf5 + crate source + release example binary +# --------------------------------------------------------------------------- +FROM ${RUST_IMAGE} + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + pkg-config \ + libhdf5-dev \ + build-essential \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && rustup component add clippy rustfmt + +WORKDIR /workspace +COPY --from=builder /src /workspace +COPY --from=builder /src/target/release/examples/load_inspect_lif /usr/local/bin/load_inspect_lif + +# Pre-warm registry cache for offline-ish agent use; ignore failure if offline. +RUN cargo fetch || true + +ENV CARGO_TERM_COLOR=always +LABEL org.opencontainers.image.title="nir-rs" \ + org.opencontainers.image.description="Pure-Rust NIR graph + HDF5 I/O toolchain image" \ + org.opencontainers.image.source="https://github.com/Limen-Neural/nir-rs" \ + org.opencontainers.image.licenses="MIT OR Apache-2.0" + +CMD ["rustc", "--version"] diff --git a/README.md b/README.md index cb3790c..7dadc67 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ **Pure-Rust implementation of the Neuromorphic Intermediate Representation (NIR)** [![CI](https://github.com/Limen-Neural/nir-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/Limen-Neural/nir-rs/actions) +[![Docker](https://github.com/Limen-Neural/nir-rs/actions/workflows/docker.yml/badge.svg)](https://github.com/Limen-Neural/nir-rs/actions/workflows/docker.yml) [![crates.io](https://img.shields.io/crates/v/nir-rs.svg)](https://crates.io/crates/nir-rs) [![docs.rs](https://docs.rs/nir-rs/badge.svg)](https://docs.rs/nir-rs) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license) @@ -62,6 +63,30 @@ Tracking: [GitHub milestones](https://github.com/Limen-Neural/nir-rs/milestones) Compatibility claims are **fixture-backed** only; see also `tests/fixtures/`. +## Docker (GHCR + Docker Hub) + +Published images ship a **Rust 1.97 + libhdf5** toolchain with the crate tree and +the `load_inspect_lif` example binary (not an SNN simulator). CI verifies on +PRs and pushes on `main` / version tags — see [`.github/workflows/docker.yml`](.github/workflows/docker.yml). + +```bash +# GitHub Container Registry +docker pull ghcr.io/limen-neural/nir-rs:0.4.1 +docker pull ghcr.io/limen-neural/nir-rs:latest + +# Docker Hub (org/user from publish config) +docker pull limenneural/nir-rs:0.4.1 # replace if DOCKER_USER differs + +docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 rustc --version +docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 load_inspect_lif --help || true +``` + +Local build: + +```bash +docker build -t nir-rs:local . +``` + ## Toolchain & MSRV **CI and local development pin Rust 1.97.1** (`rust-toolchain.toml` From 1dfe67a020d9c459c482bd5f95b386905e9dc12e Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 05:53:33 -0500 Subject: [PATCH 02/10] =?UTF-8?q?fix(docker):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20tags,=20non-root,=20fixtures,=20slim=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Publish :X.Y.Z only on matching v* tags; main gets SHA + latest - GHCR owner from github.repository_owner (lowercased) - Restrict publish to Limen-Neural/nir-rs - Runtime WORKDIR /src so example CARGO_MANIFEST_DIR fixtures work - Non-root user; drop target/ from published image - README smoke uses load_inspect_lif without || true - CI smoke runs default load_inspect_lif Co-authored-by: Grok --- .github/workflows/docker.yml | 54 ++++++++++++++++++++++++++---------- Dockerfile | 33 ++++++++++++---------- README.md | 3 +- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 616c246..ec3eb56 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,8 +2,9 @@ # Third-party Actions pinned to immutable commit SHAs. # Local Buildx only (no Docker Build Cloud). # -# PR: verify build + run tests in builder stage (contents: read only). -# main / tag: publish to Hub + GHCR (packages: write only on publish job). +# PR: verify build only (contents: read; no packages: write). +# Upstream main: SHA + latest only (never overwrite :X.Y.Z). +# Upstream v* tag matching Cargo.toml: SHA + X.Y.Z + latest. name: Docker on: @@ -60,13 +61,14 @@ jobs: - name: Smoke runtime image run: | set -euo pipefail - docker run --rm "${{ steps.image.outputs.runtime_tag }}" rustc --version - docker run --rm "${{ steps.image.outputs.runtime_tag }}" \ - sh -c 'test -x /usr/local/bin/load_inspect_lif' - docker run --rm "${{ steps.image.outputs.runtime_tag }}" \ - sh -c 'pkg-config --exists hdf5' + TAG="${{ steps.image.outputs.runtime_tag }}" + docker run --rm "${TAG}" rustc --version + docker run --rm "${TAG}" sh -c 'test -x /usr/local/bin/load_inspect_lif' + docker run --rm "${TAG}" sh -c 'pkg-config --exists hdf5' + # Default path uses fixtures under CARGO_MANIFEST_DIR=/src + docker run --rm "${TAG}" load_inspect_lif - # Builder stage already runs `cargo test --all-features` during the image build. + # Builder stage already runs cargo test --all-features during the image build. - name: Build builder stage (exercises tests inside Dockerfile) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: @@ -79,7 +81,10 @@ jobs: docker-publish: name: docker publish runs-on: ubuntu-latest - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + if: > + github.repository == 'Limen-Neural/nir-rs' && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) permissions: contents: read packages: write @@ -107,18 +112,39 @@ jobs: - name: Set image tags id: image + env: + DOCKER_USER: ${{ vars.DOCKER_USER }} + REPO_OWNER: ${{ github.repository_owner }} + REF: ${{ github.ref }} + SHA: ${{ github.sha }} run: | set -euo pipefail - SHA="${{ github.sha }}" VER=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') if [ -z "${VER}" ] || ! printf '%s' "${VER}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::Failed to extract semver X.Y.Z from Cargo.toml (got: ${VER:-empty})" exit 1 fi - HUB="${{ vars.DOCKER_USER }}/nir-rs" - GHCR="ghcr.io/limen-neural/nir-rs" - TAGS="${HUB}:${SHA},${HUB}:${VER},${HUB}:latest" - TAGS="${TAGS},${GHCR}:${SHA},${GHCR}:${VER},${GHCR}:latest" + + # Lowercase owner for GHCR (org names are case-insensitive but tags must be lower). + OWNER=$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]') + HUB="${DOCKER_USER}/nir-rs" + GHCR="ghcr.io/${OWNER}/nir-rs" + + if [ "${REF}" = "refs/heads/main" ]; then + # Never overwrite :X.Y.Z from unreleased main; only SHA + latest. + TAGS="${HUB}:${SHA},${HUB}:latest,${GHCR}:${SHA},${GHCR}:latest" + elif [[ "${REF}" == refs/tags/v* ]]; then + TAG_NAME="${REF#refs/tags/}" + if [ "${TAG_NAME}" != "v${VER}" ]; then + echo "::error::Git tag ${TAG_NAME} does not match Cargo.toml version v${VER}" + exit 1 + fi + TAGS="${HUB}:${SHA},${HUB}:${VER},${HUB}:latest,${GHCR}:${SHA},${GHCR}:${VER},${GHCR}:latest" + else + echo "::error::Unexpected ref ${REF}" + exit 1 + fi + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" echo "version=${VER}" >> "$GITHUB_OUTPUT" diff --git a/Dockerfile b/Dockerfile index a3f64f1..cdb9404 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ # syntax=docker/dockerfile:1 # nir-rs — published image for GHCR + Docker Hub. # -# Builder stage: compile/test with system libhdf5 (matches CI quality bar). -# Runtime stage: toolchain + libhdf5 for agents/consumers (not an SNN simulator). -# No Python in either stage (AGENTS.md). +# Builder: cargo test --all-features + release example with system libhdf5. +# Runtime: Rust pin + libhdf5 + source (no target/) + example binary. +# WORKDIR stays /src so env!(CARGO_MANIFEST_DIR) from the builder still finds +# tests/fixtures. Non-root user. No Python (AGENTS.md). ARG RUST_IMAGE=rust:1.97-bookworm @@ -22,11 +23,10 @@ COPY . . RUN rustup component add clippy rustfmt \ && cargo test --all-features \ - && cargo build --release --example load_inspect_lif --features hdf5 + && cargo build --release --example load_inspect_lif --features hdf5 \ + && cp target/release/examples/load_inspect_lif /tmp/load_inspect_lif \ + && rm -rf target -# --------------------------------------------------------------------------- -# Published image: Rust pin + libhdf5 + crate source + release example binary -# --------------------------------------------------------------------------- FROM ${RUST_IMAGE} RUN apt-get update \ @@ -38,16 +38,21 @@ RUN apt-get update \ ca-certificates \ curl \ && rm -rf /var/lib/apt/lists/* \ - && rustup component add clippy rustfmt + && rustup component add clippy rustfmt \ + && useradd --create-home --uid 10001 --shell /bin/bash nir -WORKDIR /workspace -COPY --from=builder /src /workspace -COPY --from=builder /src/target/release/examples/load_inspect_lif /usr/local/bin/load_inspect_lif - -# Pre-warm registry cache for offline-ish agent use; ignore failure if offline. +# Match builder path for CARGO_MANIFEST_DIR baked into the example binary. +WORKDIR /src +COPY --from=builder --chown=nir:nir /src /src +COPY --from=builder /tmp/load_inspect_lif /usr/local/bin/load_inspect_lif +RUN chmod 755 /usr/local/bin/load_inspect_lif + +USER nir +ENV CARGO_HOME=/home/nir/.cargo \ + CARGO_TERM_COLOR=always +# Pre-warm crate index for agent use; tolerate offline builders. RUN cargo fetch || true -ENV CARGO_TERM_COLOR=always LABEL org.opencontainers.image.title="nir-rs" \ org.opencontainers.image.description="Pure-Rust NIR graph + HDF5 I/O toolchain image" \ org.opencontainers.image.source="https://github.com/Limen-Neural/nir-rs" \ diff --git a/README.md b/README.md index 7dadc67..6c65a15 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ docker pull ghcr.io/limen-neural/nir-rs:latest docker pull limenneural/nir-rs:0.4.1 # replace if DOCKER_USER differs docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 rustc --version -docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 load_inspect_lif --help || true +# Default input: tests/fixtures/lif_norse.nir (writes a temp copy) +docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 load_inspect_lif ``` Local build: From 19f33544c36acd07e857e92739e7528e895052da Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 06:21:20 -0500 Subject: [PATCH 03/10] fix(docker): writable /src, no PR registry secrets, cancel races - chown /src so non-root can recreate target/; PATH includes CARGO_HOME/bin - Drop Docker Hub login from PR verify (no DOCKER_PAT on PR workflows) - concurrency cancel-in-progress on docker jobs - Remove redundant builder-target load from verify - Clarify Hub image uses vars.DOCKER_USER Co-authored-by: Grok --- .dockerignore | 1 - .github/workflows/docker.yml | 33 ++++++++++----------------------- Dockerfile | 8 ++++++-- README.md | 4 ++-- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/.dockerignore b/.dockerignore index bacf4a5..134e187 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ # Keep Docker context small and free of secrets / build junk. .git .github -.target target **/target fuzz/target diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ec3eb56..65374b8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,7 +2,7 @@ # Third-party Actions pinned to immutable commit SHAs. # Local Buildx only (no Docker Build Cloud). # -# PR: verify build only (contents: read; no packages: write). +# PR: verify build only (contents: read; no registry secrets / packages: write). # Upstream main: SHA + latest only (never overwrite :X.Y.Z). # Upstream v* tag matching Cargo.toml: SHA + X.Y.Z + latest. name: Docker @@ -15,6 +15,11 @@ on: branches: [main] types: [opened, synchronize, reopened] +# Cancel superseded publish runs so :latest cannot race backward. +concurrency: + group: docker-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -31,14 +36,7 @@ jobs: with: persist-credentials: false - - name: Log in to Docker Hub - # Same-repo human PRs may pull private base layers; Dependabot has no secrets. - if: github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.user.login != 'dependabot[bot]' - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ vars.DOCKER_USER }} - password: ${{ secrets.DOCKER_PAT }} - + # No Docker Hub / GHCR login on PRs — never expose DOCKER_PAT to PR code. - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -48,8 +46,8 @@ jobs: set -euo pipefail SHA="${{ github.sha }}" echo "runtime_tag=nir-rs:pr-${SHA}" >> "$GITHUB_OUTPUT" - echo "builder_tag=nir-rs:builder-pr-${SHA}" >> "$GITHUB_OUTPUT" + # Full Dockerfile build already runs cargo test in the builder stage. - name: Build runtime image (load locally) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: @@ -67,16 +65,8 @@ jobs: docker run --rm "${TAG}" sh -c 'pkg-config --exists hdf5' # Default path uses fixtures under CARGO_MANIFEST_DIR=/src docker run --rm "${TAG}" load_inspect_lif - - # Builder stage already runs cargo test --all-features during the image build. - - name: Build builder stage (exercises tests inside Dockerfile) - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - push: false - load: true - target: builder - tags: ${{ steps.image.outputs.builder_tag }} + # Runtime user can write target/ for agent cargo builds + docker run --rm "${TAG}" sh -c 'touch /src/.write-check && rm /src/.write-check' docker-publish: name: docker publish @@ -125,13 +115,11 @@ jobs: exit 1 fi - # Lowercase owner for GHCR (org names are case-insensitive but tags must be lower). OWNER=$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]') HUB="${DOCKER_USER}/nir-rs" GHCR="ghcr.io/${OWNER}/nir-rs" if [ "${REF}" = "refs/heads/main" ]; then - # Never overwrite :X.Y.Z from unreleased main; only SHA + latest. TAGS="${HUB}:${SHA},${HUB}:latest,${GHCR}:${SHA},${GHCR}:latest" elif [[ "${REF}" == refs/tags/v* ]]; then TAG_NAME="${REF#refs/tags/}" @@ -146,7 +134,6 @@ jobs: fi echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" - echo "version=${VER}" >> "$GITHUB_OUTPUT" - name: Build and push uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 diff --git a/Dockerfile b/Dockerfile index cdb9404..ecbc999 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ # Builder: cargo test --all-features + release example with system libhdf5. # Runtime: Rust pin + libhdf5 + source (no target/) + example binary. # WORKDIR stays /src so env!(CARGO_MANIFEST_DIR) from the builder still finds -# tests/fixtures. Non-root user. No Python (AGENTS.md). +# tests/fixtures. Non-root user owns /src so cargo can recreate target/. +# No Python (AGENTS.md). ARG RUST_IMAGE=rust:1.97-bookworm @@ -42,6 +43,8 @@ RUN apt-get update \ && useradd --create-home --uid 10001 --shell /bin/bash nir # Match builder path for CARGO_MANIFEST_DIR baked into the example binary. +# Own /src itself so the runtime user can recreate target/ for cargo build/test. +RUN mkdir -p /src && chown nir:nir /src WORKDIR /src COPY --from=builder --chown=nir:nir /src /src COPY --from=builder /tmp/load_inspect_lif /usr/local/bin/load_inspect_lif @@ -49,7 +52,8 @@ RUN chmod 755 /usr/local/bin/load_inspect_lif USER nir ENV CARGO_HOME=/home/nir/.cargo \ - CARGO_TERM_COLOR=always + CARGO_TERM_COLOR=always \ + PATH=/home/nir/.cargo/bin:/usr/local/cargo/bin:$PATH # Pre-warm crate index for agent use; tolerate offline builders. RUN cargo fetch || true diff --git a/README.md b/README.md index 6c65a15..734ea2b 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ PRs and pushes on `main` / version tags — see [`.github/workflows/docker.yml`] docker pull ghcr.io/limen-neural/nir-rs:0.4.1 docker pull ghcr.io/limen-neural/nir-rs:latest -# Docker Hub (org/user from publish config) -docker pull limenneural/nir-rs:0.4.1 # replace if DOCKER_USER differs +# Docker Hub — image is ${DOCKER_USER}/nir-rs (repo var DOCKER_USER on publish) +docker pull ${DOCKER_USER:-YOUR_DOCKERHUB_USER}/nir-rs:latest docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 rustc --version # Default input: tests/fixtures/lif_norse.nir (writes a temp copy) From 207d72e1228960023f3df2fbcc557455f8c4154b Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 06:34:05 -0500 Subject: [PATCH 04/10] docs(docker): prefer concrete GHCR pull path in README Avoid shell-placeholder Hub pulls that can target an unrelated namespace. Co-authored-by: Grok --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 734ea2b..8bd9c87 100644 --- a/README.md +++ b/README.md @@ -70,16 +70,17 @@ the `load_inspect_lif` example binary (not an SNN simulator). CI verifies on PRs and pushes on `main` / version tags — see [`.github/workflows/docker.yml`](.github/workflows/docker.yml). ```bash -# GitHub Container Registry -docker pull ghcr.io/limen-neural/nir-rs:0.4.1 +# Preferred: GitHub Container Registry (stable org path) docker pull ghcr.io/limen-neural/nir-rs:latest +# Version tag appears after a matching git tag push (e.g. v0.4.1 → :0.4.1) +docker pull ghcr.io/limen-neural/nir-rs:0.4.1 -# Docker Hub — image is ${DOCKER_USER}/nir-rs (repo var DOCKER_USER on publish) -docker pull ${DOCKER_USER:-YOUR_DOCKERHUB_USER}/nir-rs:latest +# Docker Hub: published as /nir-rs (same tags as GHCR). +# Use the org/user from GitHub Actions repo variables, not a shell placeholder. -docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 rustc --version +docker run --rm ghcr.io/limen-neural/nir-rs:latest rustc --version # Default input: tests/fixtures/lif_norse.nir (writes a temp copy) -docker run --rm ghcr.io/limen-neural/nir-rs:0.4.1 load_inspect_lif +docker run --rm ghcr.io/limen-neural/nir-rs:latest load_inspect_lif ``` Local build: From b3d720add4ee5b384d7d62e4cad5a385b6b08831 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 06:43:35 -0500 Subject: [PATCH 05/10] =?UTF-8?q?fix(docker):=20codex=20=E2=80=94=20Main?= =?UTF-8?q?=20triggers,=20shared=20latest=20concurrency,=20GHCR=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Target main and Main (REVIEW.md CI checklist) - Publish job concurrency shared across branch/tag so :latest cannot race - Best-effort set GHCR package visibility public after first push - Document GHCR public requirement in README Co-authored-by: Grok --- .github/workflows/docker.yml | 39 ++++++++++++++++++++++++++++-------- README.md | 4 ++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 65374b8..347ca14 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,17 +9,13 @@ name: Docker on: push: - branches: [main] + # REVIEW.md: workflows that change CI must keep Main + main targets. + branches: [main, Main] tags: ["v*"] pull_request: - branches: [main] + branches: [main, Main] types: [opened, synchronize, reopened] -# Cancel superseded publish runs so :latest cannot race backward. -concurrency: - group: docker-${{ github.repository }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read @@ -28,6 +24,9 @@ jobs: name: docker verify runs-on: ubuntu-latest if: github.event_name == 'pull_request' + concurrency: + group: docker-verify-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read steps: @@ -74,7 +73,11 @@ jobs: if: > github.repository == 'Limen-Neural/nir-rs' && github.event_name == 'push' && - (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/Main' || startsWith(github.ref, 'refs/tags/v')) + # Shared group for main + tag so :latest cannot race across refs. + concurrency: + group: docker-publish-${{ github.repository }} + cancel-in-progress: true permissions: contents: read packages: write @@ -141,3 +144,23 @@ jobs: context: . push: true tags: ${{ steps.image.outputs.tags }} + + # First package creation is private by default; public pulls need public visibility. + - name: Ensure GHCR package is public + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + OWNER=$(printf '%s' "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + # Org package API (works for org-owned containers). + if gh api --method PUT -H "Accept: application/vnd.github+json" "/orgs/${OWNER}/packages/container/nir-rs/visibility" -f visibility=public; then + echo "GHCR package set public (org API)" + exit 0 + fi + # User-owned fallback. + if gh api --method PUT -H "Accept: application/vnd.github+json" "/user/packages/container/nir-rs/visibility" -f visibility=public; then + echo "GHCR package set public (user API)" + exit 0 + fi + echo "::warning::Could not set GHCR visibility to public automatically; set ghcr.io/${OWNER}/nir-rs public in package settings." diff --git a/README.md b/README.md index 8bd9c87..250f73a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ docker run --rm ghcr.io/limen-neural/nir-rs:latest rustc --version docker run --rm ghcr.io/limen-neural/nir-rs:latest load_inspect_lif ``` +First GHCR publish creates a **private** package by default. The publish job +tries to set visibility to **public**; if that fails, an org admin must set +`ghcr.io/limen-neural/nir-rs` public under GitHub Packages. + Local build: ```bash From 58ec8f07826609335450bcf690bf2ba4a96fb8bc Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 13:49:39 -0500 Subject: [PATCH 06/10] fix(docker): accept Main ref; never cancel tag publishes - Tag script treats refs/heads/Main like main (SHA + latest) - Publish concurrency still shared across branch/tag but cancel-in-progress: false so v* :X.Y.Z jobs are not aborted Co-authored-by: Grok --- .github/workflows/docker.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 347ca14..e5821e7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -74,10 +74,11 @@ jobs: github.repository == 'Limen-Neural/nir-rs' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/Main' || startsWith(github.ref, 'refs/tags/v')) - # Shared group for main + tag so :latest cannot race across refs. + # Shared group serializes main + tag publishes so :latest cannot race. + # cancel-in-progress: false — never abort an in-flight version-tag publish. concurrency: group: docker-publish-${{ github.repository }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: read packages: write @@ -122,7 +123,7 @@ jobs: HUB="${DOCKER_USER}/nir-rs" GHCR="ghcr.io/${OWNER}/nir-rs" - if [ "${REF}" = "refs/heads/main" ]; then + if [ "${REF}" = "refs/heads/main" ] || [ "${REF}" = "refs/heads/Main" ]; then TAGS="${HUB}:${SHA},${HUB}:latest,${GHCR}:${SHA},${GHCR}:latest" elif [[ "${REF}" == refs/tags/v* ]]; then TAG_NAME="${REF#refs/tags/}" From 3f86a96e389fc220aad2a982ca83b88cb102608d Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 16:23:29 -0500 Subject: [PATCH 07/10] fix(docker): separate branch/tag publish concurrency groups Tag publishes never cancel and are not replaced by main queue slots; branch publishes still cancel superseded main runs. Co-authored-by: Grok --- .github/workflows/docker.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e5821e7..9b61e65 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -74,11 +74,14 @@ jobs: github.repository == 'Limen-Neural/nir-rs' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/Main' || startsWith(github.ref, 'refs/tags/v')) - # Shared group serializes main + tag publishes so :latest cannot race. - # cancel-in-progress: false — never abort an in-flight version-tag publish. + # Separate branch vs tag groups so a main push never drops a queued v* publish. + # Tags: never cancel (protect immutable :X.Y.Z). Branch: cancel superseded mains. concurrency: - group: docker-publish-${{ github.repository }} - cancel-in-progress: false + group: >- + docker-publish-${{ github.repository }}-${{ + startsWith(github.ref, 'refs/tags/') && 'tag' || 'branch' + }} + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} permissions: contents: read packages: write From 508f3e050b3db05ab7c9853a65a2dbaeaa52bb40 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 21:58:24 -0500 Subject: [PATCH 08/10] fix(docker): serialize all publishes for :latest without cancel Shared concurrency group for main and v* tags (cancel-in-progress: false) so branch/tag jobs cannot race on :latest and in-flight version-tag publishes are not aborted. Co-authored-by: Grok --- .github/workflows/docker.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9b61e65..342262b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -74,14 +74,14 @@ jobs: github.repository == 'Limen-Neural/nir-rs' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/Main' || startsWith(github.ref, 'refs/tags/v')) - # Separate branch vs tag groups so a main push never drops a queued v* publish. - # Tags: never cancel (protect immutable :X.Y.Z). Branch: cancel superseded mains. + # One group for all :latest writers (main/Main + v* tags). Serializes pushes so + # branch and tag jobs cannot race on the mutable :latest tag. + # cancel-in-progress: false keeps an in-flight v* publish (immutable :X.Y.Z) + # from being aborted by a concurrent main push. Note: GitHub still keeps only + # one *pending* job per group — avoid stacking many publishes during a release. concurrency: - group: >- - docker-publish-${{ github.repository }}-${{ - startsWith(github.ref, 'refs/tags/') && 'tag' || 'branch' - }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + group: docker-publish-${{ github.repository }} + cancel-in-progress: false permissions: contents: read packages: write From 54b7c4f341187e4e8cff714bd81b3c9ed71d18d3 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 22:10:54 -0500 Subject: [PATCH 09/10] fix(docker): queue multiple pending publishes (queue: max) Default concurrency keeps only one pending run; a later main push could replace a queued v* publish and skip the immutable :X.Y.Z image. queue: max retains pending tag jobs while still serializing all :latest writers in one group with cancel-in-progress: false. Co-authored-by: Grok --- .github/workflows/docker.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 342262b..a0e90e0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -76,12 +76,13 @@ jobs: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/Main' || startsWith(github.ref, 'refs/tags/v')) # One group for all :latest writers (main/Main + v* tags). Serializes pushes so # branch and tag jobs cannot race on the mutable :latest tag. - # cancel-in-progress: false keeps an in-flight v* publish (immutable :X.Y.Z) - # from being aborted by a concurrent main push. Note: GitHub still keeps only - # one *pending* job per group — avoid stacking many publishes during a release. + # cancel-in-progress: false — never abort an in-flight publish (incl. immutable :X.Y.Z). + # queue: max — keep up to 100 pending runs (default is single: a later main push + # would replace a pending v* tag and skip the immutable image). concurrency: group: docker-publish-${{ github.repository }} cancel-in-progress: false + queue: max permissions: contents: read packages: write From 77d9d65197c4b6bea41512ed68207865fc8b4a21 Mon Sep 17 00:00:00 2001 From: Raul Montoya Cardenas Date: Wed, 12 Aug 2026 22:40:28 -0500 Subject: [PATCH 10/10] docs(changelog): fold Docker into 0.4.1 release notes Move dual GHCR/Hub publish from Unreleased into the 0.4.1 section so the crates.io + image release story matches one version. Co-authored-by: Grok --- CHANGELOG.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbf3f81..86c25ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,6 @@ for the **0.x** series as described under [Versioning](#versioning) below. ## [Unreleased] -### Added - -- Docker image + dual publish to **GHCR** (`ghcr.io/limen-neural/nir-rs`) and - **Docker Hub** (`$DOCKER_USER/nir-rs`); PR verify / main+tag push (#26). - ## [0.4.1] - 2026-08-12 First **crates.io** release of the 0.4 line (package name `nir-rs`). @@ -27,10 +22,13 @@ First **crates.io** release of the 0.4 line (package name `nir-rs`). `MANIFEST.toml`, coverage checklist (#29). - Property tests (`proptest`) for tensor/graph/wire invariants and HDF5 write→read; optional `cargo-fuzz` harnesses under `fuzz/` (#30). +- Docker image + dual publish to **GHCR** (`ghcr.io/limen-neural/nir-rs`) and + **Docker Hub** (`$DOCKER_USER/nir-rs`); PR verify / main+tag push (#26). ### Documentation - README crates.io / docs.rs badges and dependency snippets (#26). +- README Docker (GHCR + Hub) install/run notes (#26). - [TESTING.md](TESTING.md) for property tests and local fuzz invocation. ## [0.4.0] - 2026-08-06