feat(ci): publish steward image to the registry + remote-deploy compose
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m13s
CI / publish (push) Successful in 1m12s

Add the rule-46 image-publish lane: a `publish` job gated on
[lint, unit, integration] that builds the runtime Dockerfile in ci-builder
and pushes git.fabledsword.com/bvandeusen/steward:<sha> always, :dev on dev
and :latest on main. Authenticates with the REGISTRY_TOKEN repo secret
(the injected GITHUB_TOKEN lacks write:package). Steward is the first family
app to implement the publish lane; mechanics mirror CI-runner's
build-ci-python.yml.

Add compose.deploy.yml for a persistent remote instance that pulls :dev
(no source bind-mounts, persistent app_data+pgdata volumes, bundled Postgres),
plus a README "Remote dev instance" runbook and the POSTGRES_PASSWORD env key.
Fix .env.example's database URL var to the canonical STEWARD_DATABASE_URL
(single underscore) and document the publish lane in ci-requirements.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 11:08:43 -04:00
parent 38f61b71c1
commit fe57dde57b
5 changed files with 146 additions and 3 deletions
+7 -1
View File
@@ -2,5 +2,11 @@
# These override values in config.yaml # These override values in config.yaml
STEWARD_SECRET_KEY=change-me STEWARD_SECRET_KEY=change-me
STEWARD_DATABASE__URL=postgresql+asyncpg://steward:password@localhost/steward STEWARD_DATABASE_URL=postgresql+asyncpg://steward:password@localhost/steward
STEWARD_SMTP__PASSWORD= STEWARD_SMTP__PASSWORD=
# --- compose.deploy.yml (remote instance) only ---
# Password for the bundled Postgres; the steward service builds its
# STEWARD_DATABASE_URL from it. Leave STEWARD_SECRET_KEY above unset to let the
# app generate + persist one on the /data volume.
POSTGRES_PASSWORD=change-me
+37
View File
@@ -90,3 +90,40 @@ jobs:
pip install -e '.[dev,ansible]' pip install -e '.[dev,ansible]'
fi fi
pytest tests/integration -v -m integration --durations=10 pytest tests/integration -v -m integration --durations=10
# Image-publish lane — FabledRulebook rule 46: every push to dev/main publishes
# an immutable commit-SHA image (the rollback unit); dev moves :dev, main moves
# :latest. Gated on the three test lanes via `needs:` so a red commit never
# produces a :dev image. Mechanics mirror CI-runner's build-ci-python.yml.
publish:
needs: [lint, unit, integration]
runs-on: python-ci
container:
# ci-builder carries docker + buildx + git. The docker socket is
# auto-mounted by act_runner (valid_volumes) — do NOT add a
# container.options "-v /var/run/docker.sock:..." mount; that duplicate
# mount fails the job at "Set up job".
image: git.fabledsword.com/bvandeusen/ci-builder:latest
steps:
- uses: actions/checkout@v4
- name: Registry login
# The injected GITHUB_TOKEN lacks write:package scope, so docker push
# 401s with it. REGISTRY_TOKEN is a repo Actions secret holding a
# Forgejo token scoped read:package + write:package.
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.fabledsword.com -u bvandeusen --password-stdin
- name: Build + push (commit-SHA always; :dev on dev, :latest on main)
run: |
set -euxo pipefail
IMAGE=git.fabledsword.com/bvandeusen/steward
docker build -t "$IMAGE:${{ github.sha }}" .
docker push "$IMAGE:${{ github.sha }}"
if [ "${{ github.ref }}" = "refs/heads/dev" ]; then
docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:dev"
docker push "$IMAGE:dev"
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:latest"
docker push "$IMAGE:latest"
fi
- name: Prune dangling layers
if: always()
run: docker image prune -f
+36
View File
@@ -28,6 +28,42 @@ docker compose up -d
Open `http://localhost:5000`. On first run you'll be prompted to create the admin account. Open `http://localhost:5000`. On first run you'll be prompted to create the admin account.
This compose file builds the image locally and bind-mounts the source for live editing. To run a persistent instance off the published CI image instead, see **Remote dev instance** below.
---
## Remote dev instance
For a long-lived instance on a remote server that tracks the `dev` CI line, use `compose.deploy.yml`. It pulls the published image (`git.fabledsword.com/bvandeusen/steward:dev`) rather than building, runs no source bind-mounts, and persists data in named volumes.
**One-time prerequisite — registry push token (on the Git host).** CI publishes the image, so the FabledSteward repo needs a push credential. The injected `GITHUB_TOKEN` lacks `write:package`, so create a Forgejo token scoped **`read:package` + `write:package`** and add it as the repo Actions secret **`REGISTRY_TOKEN`**. Until this exists, the `publish` CI job 401s and no `:dev` image is produced.
**Standup (on the server):**
```bash
# 1. Authenticate to the registry (read:package token is enough to pull)
docker login git.fabledsword.com -u <user>
# 2. Configure secrets
cp .env.example .env
# Set POSTGRES_PASSWORD. Leave STEWARD_SECRET_KEY unset to let the app
# generate + persist one on the /data volume.
# 3. Pull + boot
docker compose -f compose.deploy.yml up -d
```
Open `http://<server>:5000` and create the admin account on first run.
**Update to the latest dev build:**
```bash
docker compose -f compose.deploy.yml pull
docker compose -f compose.deploy.yml up -d
```
Every push to `dev` that goes CI-green publishes a fresh `:dev` (and an immutable `:<commit-sha>` for rollback). To pin a specific commit, change the `image:` tag in `compose.deploy.yml` to `:<sha>`.
--- ---
## Quick Start — Bare Metal ## Quick Start — Bare Metal
+13 -2
View File
@@ -14,8 +14,12 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
## Per-job tool installs ## Per-job tool installs
- `uv pip install --system -e '.[dev]'` (pip fallback) — in `unit` and `integration` - `uv pip install --system -e '.[dev]'` (pip fallback) — in the `unit` job.
jobs. Steward declares its deps in `pyproject.toml`; the `[dev]` extra adds - `uv pip install --system -e '.[dev,ansible]'` (pip fallback) — in the
`integration` job, which boots the real app and exercises the Ansible runner,
so it needs the `ansible` extra (`ansible>=10,<13`) at import time.
Steward declares its deps in `pyproject.toml`; the `[dev]` extra adds
`pytest` + `pytest-asyncio`. No `requirements.txt` is tracked. `pytest` + `pytest-asyncio`. No `requirements.txt` is tracked.
## Lanes ## Lanes
@@ -30,6 +34,13 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
(`create_app(testing=False)`), which runs core + every bundled plugin's (`create_app(testing=False)`), which runs core + every bundled plugin's
migrations, then round-trips the DB. This is the guard that the folded-in migrations, then round-trips the DB. This is the guard that the folded-in
plugin migration graph resolves. plugin migration graph resolves.
- **publish** — builds the runtime `Dockerfile` and pushes to the package
registry. Runs in `ci-builder:latest` (docker + buildx, socket auto-mounted),
gated on `needs: [lint, unit, integration]` so a red commit never ships an
image. Publishes `git.fabledsword.com/bvandeusen/steward:<sha>` always, plus
`:dev` on `dev` and `:latest` on `main` (FabledRulebook rule 46). Needs the
`REGISTRY_TOKEN` repo Actions secret (`read:package` + `write:package`) —
the injected `GITHUB_TOKEN` can't push packages.
## Notes ## Notes
+53
View File
@@ -0,0 +1,53 @@
# Remote deployment — pulls the published :dev image instead of building locally.
#
# This is NOT the local-dev compose. `docker-compose.yml` bind-mounts ./steward
# + ./plugins and runs --debug for live editing; THIS file runs the image as the
# source of truth (no bind-mounts, no reloader) for a persistent remote instance
# that tracks the dev CI line.
#
# Standup + update runbook: see README → "Remote dev instance".
services:
steward:
image: git.fabledsword.com/bvandeusen/steward:dev
container_name: steward
# Re-pull :dev on every `up` so restarting the stack picks up the latest
# green dev build without a manual `docker pull`.
pull_policy: always
ports:
- "5000:5000"
volumes:
# /data holds the auto-generated secret key, third-party plugins, and the
# Ansible playbook cache — all of which must survive image updates. No
# source bind-mounts: core + first-party plugins live inside the image.
- app_data:/data
environment:
- STEWARD_DATABASE_URL=postgresql+asyncpg://steward:${POSTGRES_PASSWORD:-steward}@db/steward
# Optional: if unset, the app generates a key and persists it to
# /data/secret.key, so sessions survive restarts via the app_data volume.
# Set STEWARD_SECRET_KEY in .env to pin it explicitly across volume resets.
- STEWARD_SECRET_KEY=${STEWARD_SECRET_KEY:-}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: steward
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-steward}
POSTGRES_DB: steward
volumes:
- pgdata:/var/lib/postgresql/data
# No host port published — Postgres is reachable only on the compose network.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U steward"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:
app_data: