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
87 changes: 84 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,58 @@ jobs:
- name: Run tests
run: uv run pytest -q

changes:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
web: ${{ steps.filter.outputs.web }}
steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Detect changed areas
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
# ci.yml is included so pipeline changes force one web republish,
# keeping the served build in sync with the deploy mechanism.
web:
- 'web/**'
- '.github/workflows/ci.yml'

build-web:
needs: [lint-test, changes]
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Set up Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile

- name: Build
working-directory: web
env:
# Public by nature (baked into the shipped bundle), hence a var.
VITE_API_URL: ${{ vars.WEB_API_URL }}
run: bun run build

- name: Pack
run: tar -czf finitum-web.tgz -C web/build/client .

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: finitum-web
path: finitum-web.tgz
retention-days: 1

build-push:
needs: lint-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
Expand Down Expand Up @@ -85,29 +137,58 @@ jobs:
cache-to: type=gha,mode=max

deploy:
needs: build-push
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [build-push, build-web]
# always() lets this run when build-web was legitimately SKIPPED (no web
# changes); the explicit result checks still require build-push to succeed
# and build-web to not have failed.
if: >-
always() &&
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
needs.build-push.result == 'success' &&
contains(fromJSON('["success", "skipped"]'), needs.build-web.result)
runs-on: ubuntu-latest
# Deploys serialize but NEVER cancel each other — half a `docker compose up`
# is worse than a queued deploy.
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- name: Download web build
if: needs.build-web.result == 'success'
uses: actions/download-artifact@v4
with:
name: finitum-web

# The tarball lands in the deploy user's home at a fixed path the
# server-side script expects; no sudo involved in the transfer.
- name: Upload web build to server
if: needs.build-web.result == 'success'
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.HOST_ADDRESS }}
username: ${{ secrets.HOST_USER }}
key: ${{ secrets.HOST_SSH_KEY }}
source: finitum-web.tgz
target: /home/${{ secrets.HOST_USER }}/

- name: Deploy to VPS
uses: appleboy/ssh-action@v1
env:
IMAGE_TAG: ${{ needs.build-push.outputs.sha_tag }}
DEPLOY_WEB: ${{ needs.build-web.result == 'success' }}
with:
host: ${{ secrets.HOST_ADDRESS }}
username: ${{ secrets.HOST_USER }}
key: ${{ secrets.HOST_SSH_KEY }}
# appleboy/ssh-action does not forward the runner env by default;
# pass what the remote script needs explicitly.
envs: IMAGE_TAG
envs: IMAGE_TAG,DEPLOY_WEB
# All deploy details (paths, users, compose invocation) live in a
# root-owned script on the server; the SSH user's sudoers is limited
# to exactly that script. Nothing sensitive appears in this file.
script: |
set -euo pipefail
sudo /usr/local/sbin/finitum-deploy "$IMAGE_TAG"
if [ "$DEPLOY_WEB" = "true" ]; then
sudo /usr/local/sbin/finitum-deploy --web
fi
6 changes: 5 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ Every push to `main` runs [.github/workflows/ci.yml](../.github/workflows/ci.yml

1. **lint-test** -- `ruff check` + `pytest` (also runs on pull requests, as a gate).
2. **build-push** -- builds a locked image (`uv.lock` + `uv sync --frozen`) and pushes `ghcr.io/richardhapb/finitum-api` tagged `latest` and `sha-<sha>`. The immutable sha tag is what deploys.
3. **deploy** -- SSHes into the VPS as a low-privilege deploy user and runs a single command:
3. **build-web** -- runs only when `web/**` (or the workflow itself) changed: builds the static frontend with Bun (`VITE_API_URL` from the `WEB_API_URL` repo variable -- public by nature, it's baked into the shipped bundle) and uploads it as a tarball artifact.
4. **deploy** -- SSHes into the VPS as a low-privilege deploy user. If there's a web build, it is first scp'd (no sudo) to a fixed path in the deploy user's home. Then:

```bash
sudo /usr/local/sbin/finitum-deploy "$IMAGE_TAG"
sudo /usr/local/sbin/finitum-deploy --web # only when the frontend changed
```

All environment-specific detail (paths, users, the compose invocation) lives in the root-owned `finitum-deploy` script on the server -- the public workflow file contains no infrastructure information. The script validates the image tag, does `git pull --ff-only` as the app user (only compose/config files come from git; app code lives in the image), then `docker compose pull` + `up -d` with `IMAGE_TAG` exported, and prunes only this repo's images. Prod uses `volumes: !reset []` so no host source is mounted -- the server runs exactly what CI built.

The `--web` mode publishes the frontend: it reads the tarball only from its fixed expected path (arguments never carry paths across the sudo boundary), refuses archives without an `index.html`, stages next to the web root, and swaps directories atomically, keeping the previous build as an instant-rollback backup.

## Security model

- The application files are owned by a dedicated **app user** with `nologin` -- nobody can SSH in as it, and its `.env` files are mode 0600.
Expand Down
Loading