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
100 changes: 7 additions & 93 deletions .github/workflows/web-deploy.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
name: Deploy web app

# Job 1: If ghcr.io/.../pengine-web:<tag> 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

Expand All @@ -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:
Expand All @@ -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: .
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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
Expand Down
49 changes: 21 additions & 28 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
37 changes: 23 additions & 14 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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/<owner>/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"
35 changes: 17 additions & 18 deletions doc/guides/deploying-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/pengine-web:<tag>` 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 `:<tag>`, `:sha-<short>`, 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: `:<tag>`, `:sha-<short>`, 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/<owner>/pengine-web:<tag>`** 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.

Expand Down Expand Up @@ -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

Expand All @@ -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/
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down