diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..134e187 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Keep Docker context small and free of secrets / build junk. +.git +.github +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..a0e90e0 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,171 @@ +# 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 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 + +on: + push: + # REVIEW.md: workflows that change CI must keep Main + main targets. + branches: [main, Main] + tags: ["v*"] + pull_request: + branches: [main, Main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + docker-verify: + 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: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # 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 + + - name: Set image tags + id: image + run: | + set -euo pipefail + SHA="${{ github.sha }}" + echo "runtime_tag=nir-rs: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: + context: . + push: false + load: true + tags: ${{ steps.image.outputs.runtime_tag }} + + - name: Smoke runtime image + run: | + set -euo pipefail + 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 + # 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 + runs-on: ubuntu-latest + if: > + 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')) + # 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 — 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 + 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 + env: + DOCKER_USER: ${{ vars.DOCKER_USER }} + REPO_OWNER: ${{ github.repository_owner }} + REF: ${{ github.ref }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + 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 + + OWNER=$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]') + HUB="${DOCKER_USER}/nir-rs" + GHCR="ghcr.io/${OWNER}/nir-rs" + + 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/}" + 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" + + - name: Build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + 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/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..86c25ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ecbc999 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# syntax=docker/dockerfile:1 +# nir-rs — published image for GHCR + Docker Hub. +# +# 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 owns /src so cargo can recreate target/. +# No Python (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 \ + && cp target/release/examples/load_inspect_lif /tmp/load_inspect_lif \ + && rm -rf target + +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 \ + && 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 +RUN chmod 755 /usr/local/bin/load_inspect_lif + +USER nir +ENV CARGO_HOME=/home/nir/.cargo \ + 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 + +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..250f73a 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,36 @@ 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 +# 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: 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:latest rustc --version +# Default input: tests/fixtures/lif_norse.nir (writes a temp copy) +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 +docker build -t nir-rs:local . +``` + ## Toolchain & MSRV **CI and local development pin Rust 1.97.1** (`rust-toolchain.toml`