diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index ec330ea..4101b87 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -1,13 +1,9 @@ name: Deploy web app - -# Job 1: If ghcr.io/.../pengine-web: is missing → checkout, build, push to GHCR. -# Job 2: Fetch compose (API), SSH → docker compose pull + up. - on: workflow_dispatch: inputs: tag: - description: "Registry / deploy tag (e.g. 1.0.2). Git ref for build must exist when the image is not yet in GHCR." + description: "Git tag / registry tag (e.g. 1.0.2). Image is built and pushed every run." required: true type: string @@ -24,7 +20,7 @@ env: jobs: build-package: - name: Build and push image (if not in GHCR) + name: Build and push image runs-on: ubuntu-latest timeout-minutes: 45 outputs: @@ -48,73 +44,18 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Check if package already exists in GHCR - id: registry - env: - TAG: ${{ steps.meta.outputs.tag }} - run: | - set -euo pipefail - if docker buildx imagetools inspect "${{ env.IMAGE }}:${TAG}" >/dev/null 2>&1; then - echo "exists=true" >>"$GITHUB_OUTPUT" - echo "Package ${{ env.IMAGE }}:${TAG} already in GHCR — skipping build and push." - else - echo "exists=false" >>"$GITHUB_OUTPUT" - echo "Package ${{ env.IMAGE }}:${TAG} not in GHCR — will build and push." - fi - - - name: No build needed - if: steps.registry.outputs.exists == 'true' - run: | - echo "Skipping Docker build; using existing image from registry." - - uses: actions/checkout@v4 - if: steps.registry.outputs.exists != 'true' with: ref: ${{ steps.meta.outputs.ref }} fetch-depth: 1 - # Old tags often carry a stale deploy/Dockerfile. Git fetch+checkout of main is unreliable on - # shallow clones. Overwrite deploy/Dockerfile from the GitHub API (same as main’s file). - - name: Fetch deploy/Dockerfile from default branch (Contents API) - if: steps.registry.outputs.exists != 'true' - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - REPO="${{ github.repository }}" - TOKEN="${{ secrets.GITHUB_TOKEN }}" - mkdir -p deploy - USED="" - for r in "${DEFAULT_BRANCH:-}" "main" "master"; do - [ -z "${r:-}" ] && continue - if curl -fsSL \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -o /tmp/dockerfile-api.json \ - "https://api.github.com/repos/${REPO}/contents/deploy/Dockerfile?ref=${r}"; then - USED="$r" - echo "Fetched deploy/Dockerfile from ref: ${USED}" - break - fi - echo "No deploy/Dockerfile at ref=${r}, trying next…" - done - if [ -z "${USED}" ]; then - echo "::error::Could not fetch deploy/Dockerfile from API (tried default branch, main, master)." - exit 1 - fi - jq -r .content /tmp/dockerfile-api.json | base64 -d > deploy/Dockerfile - test -s deploy/Dockerfile - - name: Git short SHA - if: steps.registry.outputs.exists != 'true' id: git run: echo "short=$(git rev-parse --short HEAD)" >>"$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@v3 - if: steps.registry.outputs.exists != 'true' - name: Build and push package to GHCR - if: steps.registry.outputs.exists != 'true' uses: docker/build-push-action@v6 with: context: . @@ -136,34 +77,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - name: Fetch deploy/docker-compose.yml (Contents API) - env: - REF: ${{ needs.build-package.outputs.ref }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - REPO="${{ github.repository }}" - TOKEN="${{ secrets.GITHUB_TOKEN }}" - USED="" - for r in "${REF}" "${DEFAULT_BRANCH}" "main" "master"; do - [ -z "${r:-}" ] && continue - if curl -fsSL \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -o /tmp/compose-api.json \ - "https://api.github.com/repos/${REPO}/contents/deploy/docker-compose.yml?ref=${r}"; then - USED="$r" - echo "Fetched deploy/docker-compose.yml from ref: ${USED}" - break - fi - echo "No deploy/docker-compose.yml at ref=${r} (404 or no access), trying fallback…" - done - if [ -z "${USED}" ]; then - echo "::error::Could not fetch deploy/docker-compose.yml (tried tag/branch '${REF}', default branch, main, master)." - exit 1 - fi - jq -r .content /tmp/compose-api.json | base64 -d > docker-compose.upload.yml - test -s docker-compose.upload.yml + # Need repo files for scp (deploy job does not inherit build-package checkout). + - uses: actions/checkout@v4 - name: Ensure remote directory uses: appleboy/ssh-action@v1.2.5 @@ -183,11 +98,11 @@ jobs: key: ${{ secrets.DEPLOY_SSH_KEY }} port: 22 timeout: 2m - source: "docker-compose.upload.yml" - target: "~/pengine/" + source: deploy/docker-compose.yml + target: ~/pengine/ overwrite: true - - name: SSH — docker compose pull on host + - name: SSH — docker login, compose pull, up uses: appleboy/ssh-action@v1.2.5 env: GHCR_USER: ${{ github.repository_owner }} @@ -203,7 +118,6 @@ jobs: envs: GHCR_USER,GHCR_TOKEN,PENGINE_WEB_IMAGE script: | set -euo pipefail - mv -f ~/pengine/docker-compose.upload.yml ~/pengine/docker-compose.yml cd ~/pengine echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin export PENGINE_WEB_IMAGE diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 83b8383..313732d 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,41 +1,34 @@ -# pengine-web: **website only** — same static bundle the Tauri desktop shell loads from -# `dist/`, without building or running the native app. -# -# Pipeline build step: `npm run build:web` (see `package.json`). Local/Tauri may use `bun run build` -# to the same `build:web` script. `build.frontendDist` → `../dist` (this image serves that on :80). -# -# Intentionally omitted: `tauri build`, Cargo, `src-tauri/` (see /.dockerignore). -# -# Build: docker build -f deploy/Dockerfile . -# Context: repository root (see /.dockerignore). - -FROM node:22-alpine +FROM node:22-alpine AS builder WORKDIR /app ENV HUSKY=0 ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY . . + ARG VITE_APP_ORIGIN=https://pengine.net ENV VITE_APP_ORIGIN=${VITE_APP_ORIGIN} -COPY . . -RUN npm install --frozen-lockfile --ignore-scripts -RUN npm run build +RUN npm run build:web + +FROM node:22-alpine AS runner +WORKDIR /app + +RUN apk add --no-cache curl + +ENV NODE_ENV=production -FROM ghcr.io/static-web-server/static-web-server:2-alpine -USER root -RUN apk add --no-cache wget -USER sws +COPY --from=builder /app /app -COPY --chown=sws:sws --from=build /app/dist /home/sws/public +RUN chown -R node:node /app +USER node -ENV SERVER_HOST=0.0.0.0 -ENV SERVER_PORT=80 -ENV SERVER_ROOT=/home/sws/public -ENV SERVER_FALLBACK_PAGE=/home/sws/public/index.html -ENV SERVER_LOG_LEVEL=error +EXPOSE 1422 -EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:1422/ >/dev/null || exit 1 -HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ - CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 +CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "1422"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3cfdad0..aec15cf 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,19 +1,28 @@ -# Runs the pengine web image on the deploy host (plain HTTP on :80 in-container, -# published to 127.0.0.1:1420). Reverse-proxy / TLS is not part of this repo — -# configure that in your ops repo (e.g. nginx → http://127.0.0.1:1420). -# -# PENGINE_WEB_IMAGE is set by CI (e.g. ghcr.io//pengine-web:1.0.1). -# Docker Compose substitutes it from the environment at `up` time. - services: - web: - image: ${PENGINE_WEB_IMAGE:?PENGINE_WEB_IMAGE is required} - container_name: pengine + pengine-web: + image: ${PENGINE_WEB_IMAGE:-ghcr.io/pengine-ai/pengine-web:latest} + container_name: pengine-app + hostname: pengine restart: unless-stopped - ports: - - "127.0.0.1:1420:80" + # Vite preview — serves `dist/` (must match deploy/Dockerfile CMD). + command: ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "1422"] + expose: + - "1422" + environment: + - NODE_ENV=production + networks: + pengui-network: + aliases: + - pengine + - app healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:1422/"] interval: 30s - timeout: 3s + timeout: 5s retries: 3 + start_period: 25s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/doc/guides/deploying-web.md b/doc/guides/deploying-web.md index 28e7986..feade93 100644 --- a/doc/guides/deploying-web.md +++ b/doc/guides/deploying-web.md @@ -6,22 +6,21 @@ The pengine web bundle is deployed to a remote host via the [`Deploy web app`](../../.github/workflows/web-deploy.yml) GitHub Actions workflow. It has two jobs: -1. **Build and push image (if not in GHCR)** — If `ghcr.io//pengine-web:` is **missing**, checks out that ref and builds [`deploy/Dockerfile`](../../deploy/Dockerfile) from the repo root (see [`/.dockerignore`](../../.dockerignore)): `bun install` → [`npm run build:web`](../../package.json) → [static-web-server](https://github.com/static-web-server/static-web-server) with **`SERVER_FALLBACK_PAGE`**. CI passes `VITE_APP_ORIGIN=https://pengine.net` as a **build-arg**. Pushes `:`, `:sha-`, and `:latest`. If the package **already exists**, this job **skips** the build and logs that fact. **Packaging** loads `deploy/Dockerfile` via the **GitHub API** from the **default branch** (with fallbacks), then builds — so it always matches `main`, not an old copy on the tag. **`deploy/docker-compose.yml`** for the host is fetched the same way in the deploy job. **App sources** (`package.json`, `src/`, …) still come from the **tag** checkout. -2. **Deploy to host** — fetches [`deploy/docker-compose.yml`](../../deploy/docker-compose.yml) via the **GitHub Contents API** (no checkout on the runner). It tries your **`tag`** ref first, then the **default branch**, **`main`**, **`master`**. **`PENGINE_WEB_IMAGE`** on the host still uses your **`tag`**. Then SSH copies the file to `~/pengine`, and the host runs **`docker login` → `docker compose pull` → `docker compose up`**. +1. **Build and push image** — Checks out the input tag and builds [`deploy/Dockerfile`](../../deploy/Dockerfile) from that revision. Steps: **`npm ci`** → [`npm run build:web`](../../package.json) → runtime **[`vite preview`](https://vite.dev/guide/cli#vite-preview)**. CI passes `VITE_APP_ORIGIN=https://pengine.net` as a **build-arg**. **Always** pushes to GHCR: `:`, `:sha-`, and `:latest`. +2. **Deploy to host** — checks out the workflow branch and **scp**’s [`deploy/docker-compose.yml`](../../deploy/docker-compose.yml) to the server, then SSH runs **`docker login`** → **`docker compose pull`** → **`docker compose up`**. **`PENGINE_WEB_IMAGE`** is **`ghcr.io//pengine-web:`** from the workflow input. -The container publishes on `127.0.0.1:1420`. **TLS and reverse-proxy (e.g. nginx) -for the public site are not defined in this repository** — maintain that in -your ops / infrastructure repo and point it at `http://127.0.0.1:1420` (or -adjust the published port in `docker-compose.yml` to match your layout). +The app listens on **port 1422** inside the container (**Vite preview** — matches +`preview.port` in `vite.config.ts`; dev server stays on **1420**). Add a **host** +port mapping in `docker-compose.yml` if the reverse proxy runs on the same machine +(e.g. `ports: "127.0.0.1:8080:1422"`). **TLS and reverse-proxy** for the public site are +not defined in this repository — point nginx (or similar) at that upstream URL. ## Triggers -- **Manual only** — Actions → *Deploy web app* → *Run workflow*. You must enter a - **`tag`** (e.g. `1.0.1` or `v1.0.1`) that exists as a **git tag** on the remote. - The workflow checks out that tag, uses the same value (with optional leading `v` - stripped) as the **GHCR image tag**, and deploys with **`docker compose pull`** - on the host. If that image is **already** in the registry, the **build** job - skips; the **deploy** job still runs. +- **Manual only** — Actions → *Deploy web app* → *Run workflow*. Enter a **`tag`** + (e.g. `1.0.1` or `v1.0.1`) that exists as a **git ref** on the remote. The workflow + checks out that ref, builds the image, **always pushes** it to GHCR for that tag, + then deploys with **`docker compose pull`** on the host. - **App release** — [`App Release`](../../.github/workflows/app-release.yml) is separate (`v*` tags for desktop); web deploy uses the **`tag`** input above. @@ -117,8 +116,8 @@ mkdir -p ~/pengine ``` Configure your external reverse-proxy (from your other repository) to forward -HTTPS traffic to `http://127.0.0.1:1420` — that is the contract between host -ingress and this deployment. +HTTPS traffic to the host port you mapped to container **1422** (for example +`http://127.0.0.1:8080` if you use `8080:1422`). ## Verifying a deploy @@ -129,13 +128,13 @@ ssh "$DEPLOY_USER@$DEPLOY_HOST" 'docker ps --filter name=pengine' curl -fsSL https://pengine.net/ | head ``` -To roll back, run *Deploy web app* again with an older **`tag`** whose image is -already in GHCR (deploy-only), or rebuild from that git tag if needed. +To roll back, run *Deploy web app* again with an older **`tag`** — the workflow +rebuilds and overwrites that tag’s image in GHCR, then deploys it. ## Local image build ```bash docker build -f deploy/Dockerfile --build-arg VITE_APP_ORIGIN=https://pengine.net -t pengine-web:local . -docker run --rm -p 8080:80 pengine-web:local -# Open http://127.0.0.1:8080 +docker run --rm -p 1422:1422 pengine-web:local +# Open http://localhost:1422/ ``` diff --git a/package.json b/package.json index fc3f33e..cfcf53c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build:web": "tsc && vite build", - "build": "npm run build:web", + "build": "bun run build:web", "preview": "vite preview", "tauri": "tauri", "test:e2e": "playwright test",