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
130 changes: 130 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Docker

on:
push:
branches: [main]
tags:
- 'v*.*.*'
pull_request:
paths:
- 'Dockerfile'
- '.dockerignore'
- 'docker-compose.yml'
- 'scripts/docker/**'
- '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 }}

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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
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
packages: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0

- name: Set up Buildx
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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Tag scheme:
# 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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
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)

- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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/<tag>/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 `:<version>`, `:<major>.<minor>`, `:<major>`, and `:latest`. Pushes to `main` publish `:main`. Both use the built-in `GITHUB_TOKEN`; no registry secrets are needed.

## Commit conventions

Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

```text
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 \
Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading