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
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Used for `docker build -f deploy/Dockerfile .` (context is repo root).
.git
.github
node_modules
dist
dist-ssr
src-tauri
test-results
playwright-report
e2e
doc
.claude
*.md
!README.md
2 changes: 2 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Used by `vite build` — public site URL (DNS + TLS terminate at the edge).
VITE_APP_ORIGIN=https://pengine.net
138 changes: 138 additions & 0 deletions .github/workflows/web-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
name: Deploy web app

# Build the pengine web image, publish to GHCR, and roll it out on the deploy
# host via docker compose under ~/pengine. This workflow only updates the app
# container (static bundle on :80 → 127.0.0.1:1420). Reverse-proxy / nginx /
# TLS for the public site is configured elsewhere (another repository).
#
# Triggers:
# - Push a tag matching v* → builds and deploys that tag
# - Manual dispatch → pick a tag to redeploy
#
# Required repo secrets:
# DEPLOY_HOST — hostname or IP of the deploy target
# DEPLOY_USER — SSH user on the deploy target
# DEPLOY_SSH_KEY — private SSH key (PEM) authorized on the target
# Optional:
# DEPLOY_HOST_KNOWN_HOSTS — verified known_hosts line(s); if unset, CI uses ssh-keyscan

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to deploy (e.g. v1.0.1)"
required: true

permissions:
contents: read
packages: write

env:
IMAGE: ghcr.io/${{ github.repository_owner }}/pengine-web

jobs:
build:
runs-on: ubuntu-latest
outputs:
image_ref: ${{ steps.ref.outputs.image_ref }}
steps:
- uses: actions/checkout@v4
with:
# Tag push: github.ref; manual: workflow input (otherwise default branch would build)
ref: ${{ github.event.inputs.tag || github.ref }}

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Resolve version
id: ver
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "version=$VERSION" >>"$GITHUB_OUTPUT"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: deploy/Dockerfile
platforms: linux/amd64
push: true
provenance: false
sbom: false
build-args: |
VITE_APP_ORIGIN=https://pengine.net
tags: |
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
${{ env.IMAGE }}:latest

- name: Export image ref
id: ref
run: echo "image_ref=${{ env.IMAGE }}@${{ steps.build.outputs.digest }}" >>"$GITHUB_OUTPUT"

deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Start SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}

- name: Trust host key
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_HOST_KNOWN_HOSTS: ${{ secrets.DEPLOY_HOST_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -n "${DEPLOY_HOST_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$DEPLOY_HOST_KNOWN_HOSTS" >>~/.ssh/known_hosts
else
ssh-keyscan -H "$DEPLOY_HOST" >>~/.ssh/known_hosts 2>/dev/null \
|| { echo "ssh-keyscan failed; set optional DEPLOY_HOST_KNOWN_HOSTS to pin the host key." >&2; exit 1; }
fi
chmod 644 ~/.ssh/known_hosts

- name: Copy docker-compose.yml to host
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" 'mkdir -p ~/pengine'
scp deploy/docker-compose.yml "$DEPLOY_USER@$DEPLOY_HOST:~/pengine/docker-compose.yml"

- name: Pull and restart
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
GHCR_USER: ${{ github.repository_owner }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IMAGE_REF: ${{ needs.build.outputs.image_ref }}
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" \
GHCR_USER="$GHCR_USER" \
GHCR_TOKEN="$GHCR_TOKEN" \
PENGINE_WEB_IMAGE="$IMAGE_REF" \
'bash -s' <<'REMOTE'
set -euo pipefail
cd ~/pengine
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
export PENGINE_WEB_IMAGE
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
REMOTE
46 changes: 46 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# pengine-web: **website only** — same static bundle the Tauri desktop shell loads from
# `dist/`, without building or running the native app.
#
# Mirrors `src-tauri/tauri.conf.json`:
# - `build.beforeBuildCommand` → `bun run build` (→ `build:web`)
# - `build.frontendDist` → `../dist` (this image serves that folder on :80)
#
# Intentionally omitted: `tauri build`, Cargo, `src-tauri/` (see /.dockerignore).
#
# Build: docker build -f deploy/Dockerfile .
# Context: repository root (see /.dockerignore).

FROM oven/bun:1 AS build
WORKDIR /app

ENV HUSKY=0
ENV NODE_ENV=production

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --ignore-scripts

COPY . .

ARG VITE_APP_ORIGIN=https://pengine.net
ENV VITE_APP_ORIGIN=${VITE_APP_ORIGIN}

# Web UI only — matches Tauri’s packaged frontend; does not invoke the Tauri CLI.
RUN bun run build:web

FROM ghcr.io/static-web-server/static-web-server:2-alpine
USER root
RUN apk add --no-cache wget
USER sws

COPY --chown=sws:sws --from=build /app/dist /home/sws/public

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 80

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1
20 changes: 20 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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 the GitHub Actions deploy step to the image
# reference being rolled out (e.g. ghcr.io/pengine-ai/pengine-web:v1.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
restart: unless-stopped
ports:
- "127.0.0.1:1420:80"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 30s
timeout: 3s
retries: 3
1 change: 1 addition & 0 deletions doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ Product overview: [../README.md](../README.md).
| [guides/skills.md](guides/skills.md) | Skills vs MCP, Dashboard behavior, `SKILL.md` / `mandatory.md`, context cap, troubleshooting |
| [guides/custom-mcp-tools.md](guides/custom-mcp-tools.md) | Concepts, dashboard vs API, `mcp.json` paths, stdio fields, Docker/custom tools, pitfalls |
| [guides/releasing.md](guides/releasing.md) | Tag-driven release pipeline, code signing/notarization explained, how to obtain Apple + Windows secrets |
| [guides/deploying-web.md](guides/deploying-web.md) | Web-app deploy pipeline: GHCR image + SSH docker-compose rollout to the host |

### Tool Engine (maintainers)

Expand Down
87 changes: 87 additions & 0 deletions doc/guides/deploying-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Deploying the web app

The public production URL for the web UI is **`https://pengine.net`** (DNS A/AAAA → your host; TLS at the reverse proxy). The Vite production build embeds this via [`VITE_APP_ORIGIN`](../../.env.production) for client-side metadata.

The pengine web bundle is deployed to a remote host via the
[`Deploy web app`](../../.github/workflows/web-deploy.yml) GitHub Actions
workflow. Each run:

1. Builds [`deploy/Dockerfile`](../../deploy/Dockerfile) from the **repo root**
(see [`/.dockerignore`](../../.dockerignore)): `bun install` → [`bun run build:web`](../../package.json)
(same as Tauri [`build.beforeBuildCommand`](../../src-tauri/tauri.conf.json) / [`build.frontendDist`](../../src-tauri/tauri.conf.json); **no** `tauri build` or Rust) → [static-web-server](https://github.com/static-web-server/static-web-server)
with **`SERVER_FALLBACK_PAGE`** so React Router paths resolve. CI passes
`VITE_APP_ORIGIN=https://pengine.net` as a **build-arg** (overridable locally).
2. Pushes the image to GHCR as `ghcr.io/<owner>/pengine-web:<version>` and
`:latest`.
3. SSHes into the deploy host, copies
[`deploy/docker-compose.yml`](../../deploy/docker-compose.yml) to `~/pengine`,
logs in to GHCR, then `docker compose pull && docker compose up -d`.

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).

## Triggers

- **Tag push** — pushing a tag matching `v*` (e.g. `v1.0.1`) deploys that tag.
The same tag also fires [`App Release`](../../.github/workflows/app-release.yml);
the two run in parallel.
- **Manual dispatch** — from the Actions tab, pick any existing tag to
redeploy it.

## Required secrets

Add under *Settings → Secrets and variables → Actions*:

| Secret | Value |
| --- | --- |
| `DEPLOY_HOST` | Hostname or IP of the deploy target |
| `DEPLOY_USER` | SSH user on the target (must be in the `docker` group) |
| `DEPLOY_SSH_KEY` | Private SSH key (PEM, including `-----BEGIN`/`-----END` lines), with its public half in the target user's `~/.ssh/authorized_keys` |

Optional:

| Secret | Value |
| --- | --- |
| `DEPLOY_HOST_KNOWN_HOSTS` | One or more `known_hosts` lines for `DEPLOY_HOST` (paste output of a **verified** `ssh-keyscan`). If omitted, the workflow runs `ssh-keyscan` at deploy time instead. |

GHCR auth on the host uses the per-run `GITHUB_TOKEN` — no extra secret
needed, but the host must be able to reach `ghcr.io` on 443.

## One-time host bootstrap

On the deploy host, as `DEPLOY_USER`:

```bash
# Docker + compose plugin (Debian/Ubuntu shown; adapt for your distro).
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
# Log out and back in so the group takes effect.

# Directory the workflow drops docker-compose.yml into.
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.

## Verifying a deploy

After the workflow succeeds:

```bash
ssh "$DEPLOY_USER@$DEPLOY_HOST" 'docker ps --filter name=pengine'
curl -fsSL https://pengine.net/ | head
```

To roll back, manually dispatch the workflow with the previous tag.

## 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
```
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "pengine",
"private": true,
"version": "1.0.0",
"version": "1.0.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:web": "tsc && vite build",
"build": "bun run build:web",
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "playwright test",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pengine"
version = "1.0.0"
version = "1.0.2"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "pengine",
"version": "1.0.0",
"version": "1.0.2",
"identifier": "com.maximedogawa.pengine",
"build": {
"beforeDevCommand": "bun run dev",
Expand Down
Loading