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 deleted file mode 100644 index 0a22405..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,49 +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). - -on: - push: - branches: [main] - 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: 22 - 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 7bb2a64..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,66 +0,0 @@ -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. -on: - push: - branches: [main] - tags: ['v*'] - pull_request: - workflow_dispatch: - -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=raw,value=latest,enable={{is_default_branch}} - type=sha - 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 87321d5..4ceb43b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,18 +4,26 @@ # 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=/ 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} 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',