From 091981ea1443f8e6c73524bd78600f20264ea8fd Mon Sep 17 00:00:00 2001 From: JuanCF Date: Mon, 24 Aug 2026 23:11:09 -0600 Subject: [PATCH 1/3] feat: publish multi-arch Docker images to GHCR Installing via Docker previously required cloning the repo to build the image locally. Publish prebuilt images instead so a compose file (or a plain docker run) is enough. - Add .github/workflows/docker-publish.yml, building linux/amd64 and linux/arm64 and pushing to ghcr.io/juancf/nutwatch. Version tags publish :X.Y.Z, :X.Y, :X and :latest; pushes to main publish a rolling :main. Pull requests build amd64 only and push nothing. Auth uses the built-in GITHUB_TOKEN, so no registry secrets are needed. - Pin the frontend build stage to $BUILDPLATFORM. The SPA and the backend sources copied alongside it are arch-independent, so npm no longer runs under QEMU emulation when cross-building the arm64 image. - Point docker-compose.yml at the published image, keeping `build: .` available as a commented-out option for source builds. - Document the pull-based install, the tag scheme and the upgrade path in README.md, and note the image tags in the release checklist. --- .github/workflows/docker-publish.yml | 76 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 + Dockerfile | 5 +- README.md | 41 ++++++++++++--- docker-compose.yml | 5 +- 5 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b2a2780 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,76 @@ +name: Docker + +on: + push: + branches: [main] + tags: + - 'v*.*.*' + pull_request: + paths: + - 'Dockerfile' + - '.dockerignore' + - 'docker-compose.yml' + - 'scripts/docker/**' + - 'src/**' + - '.github/workflows/docker-publish.yml' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + # Pull requests build for validation only; nothing is pushed. + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Tag scheme: + # v1.3.0 -> :1.3.0, :1.3, :1, :latest + # main -> :main (rolling development build) + - name: Derive image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=ref,event=branch + type=ref,event=pr + labels: | + org.opencontainers.image.title=NutWatch + org.opencontainers.image.description=Web UI for managing Network UPS Tools (NUT) + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + # Pull requests validate on amd64 only; emulated arm64 roughly + # doubles the run time and adds little for a build-only check. + platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ffaca9..ab192b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ When cutting a new release (e.g. `v1.2.0`), update the version string in these f | `src/frontend/package.json` | `version` field | `"version": "1.2.0"` | | `src/frontend/src/constants/index.ts` | `APP_VERSION` constant (line ~99) | `export const APP_VERSION = 'v1.2.0';` | | `README.md` | `NUTWATCH_REF` in both env tables (lines ~375, ~451) | `\| \`NUTWATCH_REF\` \| \`v1.2.0\` \| ...` | +| `README.md` | Docker image tag table in the Docker section | `\| \`1.2.0\`, \`1.2\`, \`1\` \| Specific release ... \|` | Also check `scripts/setup.sh` for version strings in the header comment (line ~14) and the help output (line ~715). @@ -22,6 +23,7 @@ Also check `scripts/setup.sh` for version strings in the header comment (line ~1 - The GitHub Release tag triggers the `.github/workflows/release.yml` workflow, which builds and publishes the NutWatch tarball. - **Tags must be ancestors of `origin/main`.** The CI workflow (`check-tag-branch` job) enforces this before any downstream jobs run. If a tag is pushed from a feature branch, the release is rejected. - The `NUTWATCH_REF` variable in the scripts points to the Git tag, which maps to the release download URL at `https://github.com/JuanCF/nutwatch/releases/download//nutwatch.tar.gz`. +- The same tag also triggers `.github/workflows/docker-publish.yml`, which pushes the multi-arch image to `ghcr.io/juancf/nutwatch` as `:`, `:.`, `:`, and `:latest`. Pushes to `main` publish `:main`. Both use the built-in `GITHUB_TOKEN`; no registry secrets are needed. ## Commit conventions diff --git a/Dockerfile b/Dockerfile index d073129..792585a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,10 @@ # Multi-stage build for NutWatch. # Stage 1 builds the React frontend; Stage 2 is the runtime image with NUT. -FROM node:22-slim AS frontend-builder +# Pinned to the build host's architecture: the SPA and the backend sources it +# is copied alongside are both arch-independent, so there is no reason to run +# npm under QEMU emulation when cross-building the arm64 image. +FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-builder # Build the React SPA. Vite writes to ../backend/static, so we copy the # backend tree into the same relative location before building. WORKDIR /build/src/frontend diff --git a/README.md b/README.md index 35ceb37..23d02bf 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This repository also includes `vm/nut-vm.sh`, a bash script to automatically cre ### CI/CD & Developer Tooling -- **GitHub Actions** — Lint (shellcheck, shfmt), Python syntax check (py_compile), pytest, frontend tests (Vitest), and automated release workflow on tag push +- **GitHub Actions** — Lint (shellcheck, shfmt), Python syntax check (py_compile), pytest, frontend tests (Vitest), automated release workflow on tag push, and multi-arch Docker image publishing to GHCR - **Makefile** — `check`, `lint`, `fmt`, `fmt-fix`, `lint-python`, `test-python`, `tsc-check`, `lint-frontend`, `build-frontend`, `build-tarball`, `install-tools` - **`build-tarball`** — Creates `nutwatch.tar.gz` for release distribution (git-ignored) - **Backend Tests** — Pytest suite covering parsers, service layer (UPS, users, upsmon, hooks, WOL, system), auth, route handlers, and utility functions @@ -440,15 +440,26 @@ admin role. ### Docker -A multi-stage `Dockerfile` and `docker-compose.yml` are included. The image +Prebuilt multi-arch images (`linux/amd64`, `linux/arm64`) are published to the +GitHub Container Registry, so there's no need to clone the repo. The image bundles NUT, the NutWatch backend, and a built React frontend. +``` +ghcr.io/juancf/nutwatch +``` + +| Tag | Points at | +|-----|-----------| +| `latest` | Most recent release | +| `1.3.0`, `1.3`, `1` | Specific release / minor / major line | +| `main` | Rolling build of the `main` branch (unreleased) | + ```bash -# Build and run with Docker Compose +# Run with Docker Compose — grab just the compose file, no clone required +curl -fsSLO https://raw.githubusercontent.com/JuanCF/nutwatch/main/docker-compose.yml docker compose up -d -# Or build and run manually -docker build -t nutwatch . +# Or run the image directly docker run -d \ --name nutwatch \ --device /dev/bus/usb:/dev/bus/usb \ @@ -457,9 +468,20 @@ docker run -d \ -p 3493:3493 \ -v nutwatch-config:/etc/nut \ -v nutwatch-data:/var/lib/nutwatch \ - nutwatch + ghcr.io/juancf/nutwatch:latest +``` + +To upgrade, pull the new image and recreate the container — both volumes are +preserved: + +```bash +docker compose pull && docker compose up -d ``` +**Building from source instead.** A multi-stage `Dockerfile` is included; clone +the repo, uncomment the `build: .` line in `docker-compose.yml`, and run +`docker compose up -d --build` (or `docker build -t nutwatch .`). + **USB access.** NUT drivers need to talk to the UPS over USB. The container gets the host USB bus plus an allow-rule for USB character devices: @@ -608,6 +630,13 @@ git push origin v1.2.3 The GitHub Actions workflow will run lint checks, build `nutwatch.tar.gz`, and create a GitHub Release. +In parallel, `.github/workflows/docker-publish.yml` builds the `linux/amd64` + +`linux/arm64` image and pushes it to `ghcr.io/juancf/nutwatch` as `:1.2.3`, +`:1.2`, `:1`, and `:latest`. Pushes to `main` publish a rolling `:main` tag, and +pull requests touching the Docker or app sources build the image without +pushing. Authentication uses the built-in `GITHUB_TOKEN` — no secrets to +configure. + ### Testing a Local Build ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 9aa0c03..adf7386 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: nutwatch: - build: . + image: ghcr.io/juancf/nutwatch:latest + # Building from source instead? Clone the repo, uncomment the line below, + # and run `docker compose up -d --build`. + # build: . container_name: nutwatch # USB access: mount the host USB bus and allow USB character devices via # the device cgroup. The cgroup rule is evaluated when devices appear, so From 989696750f9e15f9c1872185cf74ab697532cdce Mon Sep 17 00:00:00 2001 From: JuanCF Date: Mon, 24 Aug 2026 23:26:48 -0600 Subject: [PATCH 2/3] fix: guard Docker publishes the same way tarball releases are guarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.yml gates every job behind check-tag-branch, so a tag that is not an ancestor of main is rejected before anything is published. The Docker workflow had no equivalent, so such a tag would still publish an image — including :latest. The two workflows disagreed about what a valid release is, and the stricter one was the one that did not touch the registry. - Add the same check-tag-branch guard, scoped to tag pushes, and make the build job depend on it. The build's !cancelled() && !failure() condition keeps branch and pull-request runs working while the guard is skipped, and still blocks the build when the guard rejects a tag. - Set flavor latest=false and apply :latest through an explicit raw entry gated on the tag being the highest v*.*.* in the repo. metadata-action's latest=auto tags :latest on any semver tag with no version comparison, so re-pushing an older tag would have dragged :latest backwards onto a superseded release. --- .github/workflows/docker-publish.yml | 52 +++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b2a2780..d1c6410 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -19,7 +19,51 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + # Mirrors the guard in release.yml: a tag that is not an ancestor of main + # must not publish an image, the same way it must not publish a tarball. + # Also decides whether this tag is the newest one, so that re-pushing an + # older tag cannot drag :latest backwards onto a superseded release. + check-tag-branch: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + is-latest: ${{ steps.newest.outputs.is-latest }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Verify tag is on main branch + run: | + TAG_SHA=$(git rev-parse "${{ github.ref }}") + if ! git merge-base --is-ancestor "$TAG_SHA" origin/main 2>/dev/null && \ + [ "$TAG_SHA" != "$(git rev-parse origin/main)" ]; then + echo "ERROR: Tag ${{ github.ref_name }} is not on the main branch." + echo "Push tags from commits on the main branch only." + exit 1 + fi + + - name: Determine whether this is the newest release tag + id: newest + run: | + highest=$(git tag -l 'v*.*.*' | sort -V | tail -n1) + if [ "${{ github.ref_name }}" = "$highest" ]; then + echo "is-latest=true" >> "$GITHUB_OUTPUT" + echo "${{ github.ref_name }} is the newest tag; :latest will be updated." + else + echo "is-latest=false" >> "$GITHUB_OUTPUT" + echo "${{ github.ref_name }} is superseded by $highest; :latest will be left alone." + fi + build: + needs: [check-tag-branch] + # check-tag-branch only runs for tag pushes. Without this condition the + # build job would be skipped alongside it on branch and pull-request + # events; !failure() still blocks the build when the guard rejects a tag. + if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-latest permissions: contents: read @@ -45,19 +89,25 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} # Tag scheme: - # v1.3.0 -> :1.3.0, :1.3, :1, :latest + # v1.3.0 -> :1.3.0, :1.3, :1 (plus :latest when newest) # main -> :main (rolling development build) - name: Derive image tags id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # latest=false disables the automatic "any semver tag wins :latest" + # behaviour; the explicit raw entry below applies it only to the + # newest tag. + flavor: | + latest=false tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=ref,event=branch type=ref,event=pr + type=raw,value=latest,enable=${{ needs.check-tag-branch.outputs.is-latest == 'true' }} labels: | org.opencontainers.image.title=NutWatch org.opencontainers.image.description=Web UI for managing Network UPS Tools (NUT) From f2f94ec5d078dce6ab71f8beb033b3f924a8c6af Mon Sep 17 00:00:00 2001 From: JuanCF Date: Mon, 24 Aug 2026 23:31:26 -0600 Subject: [PATCH 3/3] chore: pin Docker workflow actions to commit SHAs Address CodeRabbit review feedback on #56. The build job holds packages: write and can publish to GHCR, so a moving major-version tag on any of its actions is a path to unauthorized image publishes. Pin all six to the commit SHA each tag currently resolves to, with the human-readable version retained in a trailing comment. Also declare a restrictive top-level permissions block, matching lint.yml and silencing zizmor's excessive-permissions warning; both jobs already set their own permissions, so effective access is unchanged. Label the registry fence in README.md as text (markdownlint MD040), consistent with the other fences in the file. --- .github/workflows/docker-publish.yml | 18 +++++++++++------- README.md | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d1c6410..20aee31 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -14,6 +14,10 @@ on: - 'src/**' - '.github/workflows/docker-publish.yml' +# Restrictive default; each job narrows or widens this explicitly. +permissions: + contents: read + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -31,7 +35,7 @@ jobs: outputs: is-latest: ${{ steps.newest.outputs.is-latest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 persist-credentials: false @@ -69,20 +73,20 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 # Pull requests build for validation only; nothing is pushed. - name: Log in to GHCR if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -93,7 +97,7 @@ jobs: # main -> :main (rolling development build) - name: Derive image tags id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # latest=false disables the automatic "any semver tag wins :latest" @@ -113,7 +117,7 @@ jobs: org.opencontainers.image.description=Web UI for managing Network UPS Tools (NUT) - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . # Pull requests validate on amd64 only; emulated arm64 roughly diff --git a/README.md b/README.md index 23d02bf..b6ea33c 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ Prebuilt multi-arch images (`linux/amd64`, `linux/arm64`) are published to the GitHub Container Registry, so there's no need to clone the repo. The image bundles NUT, the NutWatch backend, and a built React frontend. -``` +```text ghcr.io/juancf/nutwatch ```