From 5f87d6371b6ca49ed3c278d75eecea8699672ae2 Mon Sep 17 00:00:00 2001 From: mannes Date: Thu, 24 Sep 2026 08:37:48 +0200 Subject: [PATCH 1/3] Build on Node 26 Node 26 becomes Active LTS in October; move the Docker build stage and the GitHub Actions workflows from Node 22 to 26. - Dockerfile: Node 26 no longer bundles corepack; install it from npm so packageManager in package.json still pins pnpm. - apps/web tests: run with --no-experimental-webstorage. Node 25+ has a global localStorage that shadows jsdom's and broke the useTheme tests. --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- Dockerfile | 4 ++-- apps/web/vite.config.ts | 3 +++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b18db6b..b065b38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0a22405..853a22e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,7 +30,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm --filter web build diff --git a/Dockerfile b/Dockerfile index 87321d5..b78fda7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,9 @@ # docker build -t multimeter . # served at / # docker build --build-arg BASE_PATH=/multimeter/ -t multimeter . # served at /multimeter/ -FROM node:22-alpine AS build +FROM node:26-alpine AS build WORKDIR /app -RUN corepack enable +RUN npm install -g corepack && corepack enable COPY . . RUN pnpm install --frozen-lockfile ARG BASE_PATH=/ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cf94eea..53fc6c9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -52,6 +52,9 @@ export default defineConfig(({ mode }) => { server: { host: true }, test: { environment: 'jsdom', + // Node 25+ ships its own global localStorage (unusable without --localstorage-file), which + // shadows jsdom's. Turn it off so tests get the jsdom one. + execArgv: ['--no-experimental-webstorage'], setupFiles: ['./src/test/setup.ts'], coverage: { provider: 'v8', From 62be883c858527aeda29cccc3a62f28ea641f28a Mon Sep 17 00:00:00 2001 From: mannes Date: Thu, 24 Sep 2026 08:40:31 +0200 Subject: [PATCH 2/3] Release on v* tags: Pages deploy and container image Merging to main no longer publishes anything. Pushing a v* tag deploys to GitHub Pages and pushes ghcr.io/libreble/multimeter as , . and latest. Pull requests still build and smoke-test the image; the Pages workflow can still be run by hand from main. --- .github/workflows/deploy.yml | 4 +++- .github/workflows/docker.yml | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 853a22e..97b7577 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,9 +5,11 @@ name: Deploy to GitHub Pages # deployment → Source). Served as a project site at libreble.github.io/multimeter/, so the # repo must be named `multimeter` (Vite `base` is /multimeter/ to match). +# Releases only: push a v* tag to publish. Merging to main doesn't deploy. +# (workflow_dispatch on main redeploys main by hand, e.g. after a Pages outage.) on: push: - branches: [main] + tags: ['v*'] workflow_dispatch: permissions: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7bb2a64..3e69834 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,13 +1,12 @@ name: Container image # Publishes ghcr.io/libreble/ (linux/amd64 + linux/arm64) with the built-in -# GITHUB_TOKEN — no registry account or secrets. Pull requests build and smoke-test only. +# GITHUB_TOKEN — no registry account or secrets. Releases only: a v* tag pushes +# , . and latest. Pull requests build and smoke-test only. on: push: - branches: [main] tags: ['v*'] pull_request: - workflow_dispatch: permissions: contents: read @@ -30,8 +29,6 @@ jobs: with: images: ghcr.io/${{ github.repository }} tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} From 982df420292af1635c670da295bbdaf461f57bde Mon Sep 17 00:00:00 2001 From: mannes Date: Thu, 24 Sep 2026 08:44:12 +0200 Subject: [PATCH 3/3] One release workflow: install and build once for Pages and the image deploy.yml and docker.yml become release.yml. A single job installs once and builds on the runner, uploads the Pages artifact and builds the image from the prebuilt output (new Dockerfile target `prebuilt`, fed via --build-context dist=...). The arm64 image no longer runs an emulated Node build. Building the Dockerfile from source (the default target) is unchanged for self-hosters. --- .github/workflows/deploy.yml | 51 ------------------- .github/workflows/docker.yml | 63 ----------------------- .github/workflows/release.yml | 96 +++++++++++++++++++++++++++++++++++ Dockerfile | 12 ++++- 4 files changed, 106 insertions(+), 116 deletions(-) delete mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 97b7577..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Deploy to GitHub Pages - -# Build the Vite app (apps/web) and publish its dist/ to GitHub Pages on every push to main. -# Pages must be set to "GitHub Actions" as its source (Settings → Pages → Build and -# deployment → Source). Served as a project site at libreble.github.io/multimeter/, so the -# repo must be named `multimeter` (Vite `base` is /multimeter/ to match). - -# Releases only: push a v* tag to publish. Merging to main doesn't deploy. -# (workflow_dispatch on main redeploys main by hand, e.g. after a Pages outage.) -on: - push: - tags: ['v*'] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -# One deploy at a time; let a newer push supersede an in-flight run. -concurrency: - group: pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - # Version comes from package.json "packageManager" (pnpm@9.15.9). Don't also set `version` - # here — pnpm/action-setup errors on a double declaration (ERR_PNPM_BAD_PM_VERSION). - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 26 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm --filter web build - - uses: actions/upload-pages-artifact@v5 - with: - path: apps/web/dist - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 3e69834..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Container image - -# Publishes ghcr.io/libreble/ (linux/amd64 + linux/arm64) with the built-in -# GITHUB_TOKEN — no registry account or secrets. Releases only: a v* tag pushes -# , . and latest. Pull requests build and smoke-test only. -on: - push: - tags: ['v*'] - pull_request: - -permissions: - contents: read - packages: write - -concurrency: - group: docker-${{ github.ref }} - cancel-in-progress: true - -jobs: - image: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - - # Build for the runner, run it, and check the page, assets, SW, manifest and SPA fallback. - - uses: docker/build-push-action@v6 - with: - context: . - load: true - tags: smoke:test - cache-from: type=gha - cache-to: type=gha,mode=max - - run: docker run -d --name smoke -p 8080:8080 smoke:test - - run: ./docker/smoke.sh http://localhost:8080/ - - if: failure() - run: docker logs smoke - - - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - if: github.event_name != 'pull_request' - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - cache-from: type=gha diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..abde790 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +# Releases only: push a v* tag. Merging to main publishes nothing. +# +# One job installs once and builds on the runner — twice, because Vite bakes the base path in: +# default base (/multimeter/) → GitHub Pages BASE_PATH=/ → the container image +# The image copies that prebuilt apps/web/dist/ into nginx (Dockerfile target `prebuilt`), so the +# arm64 variant needs no emulated Node build. Pull requests run the same build and +# smoke-test the image without publishing; workflow_dispatch on main redeploys Pages. +on: + push: + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + pages: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 # reads packageManager from package.json + - uses: actions/setup-node@v6 + with: + node-version: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build for GitHub Pages + run: pnpm --filter web build && mv apps/web/dist pages-dist + - name: Build for the container image + run: BASE_PATH=/ pnpm --filter web build + + - if: github.event_name != 'pull_request' + uses: actions/configure-pages@v6 + - if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v5 + with: + path: pages-dist + + # ghcr.io/libreble/multimeter (linux/amd64 + linux/arm64) via the built-in GITHUB_TOKEN. + - uses: docker/setup-buildx-action@v3 + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + - uses: docker/build-push-action@v6 + with: + context: . + target: prebuilt + build-contexts: dist=apps/web/dist + load: true + tags: smoke:test + - run: docker run -d --name smoke -p 8080:8080 smoke:test + - run: ./docker/smoke.sh http://localhost:8080/ + - if: failure() + run: docker logs smoke + - if: startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - if: startsWith(github.ref, 'refs/tags/v') + uses: docker/build-push-action@v6 + with: + context: . + target: prebuilt + build-contexts: dist=apps/web/dist + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + + deploy: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/Dockerfile b/Dockerfile index b78fda7..4ceb43b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,18 @@ RUN pnpm install --frozen-lockfile ARG BASE_PATH=/ RUN BASE_PATH="$BASE_PATH" pnpm --filter web build -FROM nginxinc/nginx-unprivileged:1.29-alpine +FROM nginxinc/nginx-unprivileged:1.29-alpine AS serve ARG BASE_PATH=/ ENV BASE_PATH=$BASE_PATH COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template -COPY --from=build /app/apps/web/dist /usr/share/nginx/html${BASE_PATH} EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO /dev/null http://127.0.0.1:8080/healthz || exit 1 + +# Release CI: serve a dist/ already built on the runner (.github/workflows/release.yml): +# docker buildx build --target prebuilt --build-context dist=apps/web/dist . +FROM serve AS prebuilt +COPY --from=dist . /usr/share/nginx/html${BASE_PATH} + +# Default: build from source. +FROM serve +COPY --from=build /app/apps/web/dist /usr/share/nginx/html${BASE_PATH}