Add reusable deployment helper
All checks were successful
Publish container image / Build and push image (push) Successful in 2s

This commit is contained in:
2026-05-21 21:55:27 +02:00
parent eb2b154bf4
commit 3d4eb50d1c
4 changed files with 289 additions and 0 deletions

View File

@@ -71,6 +71,55 @@ docker compose up -d --build
Then open `http://<host>:8091`.
## Deployment helper for published images
For host deployments on this machine, use `deploy/deploy.sh` instead of editing
compose files by hand. The script is meant to be copied into other repos too: it
infers the repo name from `origin`, looks up the numeric Gitea repo id via the
API, computes the host port as `5000 + 10*repo_id + service_number`, copies the
repo's deployment assets, and writes a ready-to-run deployment directory.
For Arcana FX the Gitea repo id is `4`, so the default deployment port is:
```text
5000 + 10*4 + 0 = 5040
```
Generate a deployment directory for a tagged image:
```bash
./deploy/deploy.sh v0.1.0
```
That creates `~/deployments/arcana-fx-0/` containing:
- `docker-compose.yml` pinned to `192.168.33.22:3300/gemma/arcana-fx:v0.1.0`
- `.env` copied from `.env.example` on first run
- `config/` copied from the repo via `deploy/include-paths.txt`
- `.deploy.meta` with the resolved repo id, version, and host port
Then edit the generated `.env` and start it:
```bash
cd ~/deployments/arcana-fx-0
$EDITOR .env
docker compose pull
docker compose up -d
```
A second service instance can use another service number, which shifts the port
by 1 while keeping the same repo-id block:
```bash
./deploy/deploy.sh v0.1.0 1
# host port becomes 5041
```
When copying this deployment helper to another repo, update
`deploy/docker-compose.template.yml` for that app's internal port and mounts,
and `deploy/include-paths.txt` for any extra files/directories that must travel
with the generated compose file.
## Container publishing pipeline
A Gitea Actions workflow lives at `.gitea/workflows/release-container.yml`.

214
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# deploy.sh — prepare a deployment directory for the current repository.
#
# Designed to be copied verbatim into other Gitea-hosted service repos on this
# machine. Repo-specific values (service name, image path, project id) are
# discovered from the `origin` remote and the Gitea API, so the only thing
# baked into the convention is the host-port formula:
#
# host_port = 5000 + 10 * <gitea_project_id> + <service_number>
#
# Usage:
# deploy/deploy.sh <version> [service_number]
#
# Output:
# <DEPLOY_ROOT>/<repo>-<service_number>/docker-compose.yml
# <DEPLOY_ROOT>/<repo>-<service_number>/.deploy.meta
# <DEPLOY_ROOT>/<repo>-<service_number>/.env (copied from .env.example
# on first run if absent)
#
# Environment overrides:
# DEPLOY_ROOT base directory (default: $HOME/deployments)
# TEMPLATE path to compose template (default:
# <repo>/deploy/docker-compose.template.yml)
# GITEA_BASE_URL e.g. http://192.168.33.22:3300
# (default: http://<remote-host>:3300)
# GITEA_API_BASE full API base (default: $GITEA_BASE_URL/api/v1)
# GITEA_TOKEN token for private repos (optional)
# GITEA_PROJECT_ID skip API discovery and use this id
# REGISTRY container registry host[:port]
# (default: <remote-host>:3300)
# IMAGE_REPO full image path host[:port]/owner/repo
# (default: $REGISTRY/<owner>/<repo>)
# INCLUDE_PATHS_FILE newline-delimited repo-relative paths to copy into the
# deployment dir (default: <repo>/deploy/include-paths.txt)
set -euo pipefail
die() { printf 'deploy: %s\n' "$*" >&2; exit 1; }
usage() {
sed -n '2,/^set -euo pipefail$/p' "$0" \
| sed '$d' \
| sed 's/^# \{0,1\}//'
exit 1
}
[ "$#" -ge 1 ] || usage
case "$1" in -h|--help|help) usage;; esac
VERSION="$1"
SERVICE_NUMBER="${2:-0}"
case "$SERVICE_NUMBER" in
''|*[!0-9]*) die "service_number must be a non-negative integer: $SERVICE_NUMBER";;
esac
case "$VERSION" in
''|*[[:space:]]*) die "version must be non-empty and contain no whitespace";;
esac
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)"
REPO_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" \
|| die "must be run from inside a git working tree"
TEMPLATE="${TEMPLATE:-$REPO_DIR/deploy/docker-compose.template.yml}"
DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/deployments}"
INCLUDE_PATHS_FILE="${INCLUDE_PATHS_FILE:-$REPO_DIR/deploy/include-paths.txt}"
[ -r "$TEMPLATE" ] || die "template not found: $TEMPLATE"
remote_url="$(git -C "$REPO_DIR" remote get-url origin)" \
|| die "no 'origin' remote configured"
REMOTE_HOST=""; REMOTE_OWNER=""; REMOTE_REPO=""
parse_remote() {
local url="$1" rest host_part path_part
url="${url%.git}"
case "$url" in
ssh://*)
rest="${url#ssh://}"
rest="${rest#*@}"
host_part="${rest%%/*}"
path_part="${rest#*/}"
REMOTE_HOST="${host_part%%:*}"
;;
http://*|https://*)
rest="${url#*://}"
host_part="${rest%%/*}"
path_part="${rest#*/}"
REMOTE_HOST="${host_part%%:*}"
;;
*@*:*)
rest="${url#*@}"
REMOTE_HOST="${rest%%:*}"
path_part="${rest#*:}"
;;
*)
die "cannot parse git remote URL: $url"
;;
esac
REMOTE_OWNER="${path_part%/*}"
REMOTE_REPO="${path_part##*/}"
[ -n "$REMOTE_HOST" ] && [ -n "$REMOTE_OWNER" ] && [ -n "$REMOTE_REPO" ] \
|| die "could not extract host/owner/repo from: $url"
}
parse_remote "$remote_url"
GITEA_BASE_URL="${GITEA_BASE_URL:-http://${REMOTE_HOST}:3300}"
GITEA_API_BASE="${GITEA_API_BASE:-$GITEA_BASE_URL/api/v1}"
REGISTRY="${REGISTRY:-${REMOTE_HOST}:3300}"
IMAGE_REPO="${IMAGE_REPO:-${REGISTRY}/${REMOTE_OWNER}/${REMOTE_REPO}}"
if [ -z "${GITEA_PROJECT_ID:-}" ]; then
api_url="${GITEA_API_BASE}/repos/${REMOTE_OWNER}/${REMOTE_REPO}"
printf 'deploy: looking up project id at %s\n' "$api_url" >&2
curl_args=(-fsS --max-time 10)
if [ -n "${GITEA_TOKEN:-}" ]; then
curl_args+=(-H "Authorization: token ${GITEA_TOKEN}")
fi
if ! resp="$(curl "${curl_args[@]}" "$api_url")"; then
die "Gitea API call failed: $api_url
set GITEA_PROJECT_ID=<n> to bypass discovery, or
set GITEA_API_BASE / GITEA_TOKEN to override defaults."
fi
GITEA_PROJECT_ID="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" \
|| die "could not extract repo id from Gitea response"
fi
case "$GITEA_PROJECT_ID" in
''|*[!0-9]*) die "GITEA_PROJECT_ID is not a non-negative integer: $GITEA_PROJECT_ID";;
esac
HOST_PORT=$(( 5000 + 10 * GITEA_PROJECT_ID + SERVICE_NUMBER ))
deploy_dir="$DEPLOY_ROOT/${REMOTE_REPO}-${SERVICE_NUMBER}"
mkdir -p "$deploy_dir"
out_file="$deploy_dir/docker-compose.yml"
if [ -r "$INCLUDE_PATHS_FILE" ]; then
while IFS= read -r include_path || [ -n "$include_path" ]; do
include_path="${include_path%%#*}"
include_path="$(printf '%s' "$include_path" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[ -n "$include_path" ] || continue
src_path="$REPO_DIR/$include_path"
dst_path="$deploy_dir/$include_path"
[ -e "$src_path" ] || die "include path listed but missing: $include_path"
mkdir -p "$(dirname "$dst_path")"
rm -rf "$dst_path"
cp -R "$src_path" "$dst_path"
done < "$INCLUDE_PATHS_FILE"
fi
awk \
-v image="$IMAGE_REPO" \
-v tag="$VERSION" \
-v host_port="$HOST_PORT" \
-v service="$REMOTE_REPO" \
-v service_num="$SERVICE_NUMBER" \
-v project_id="$GITEA_PROJECT_ID" \
'{
gsub(/__IMAGE_TAG__/, tag);
gsub(/__IMAGE__/, image);
gsub(/__HOST_PORT__/, host_port);
gsub(/__SERVICE_NAME__/, service);
gsub(/__SERVICE_NUMBER__/, service_num);
gsub(/__GITEA_PROJECT_ID__/, project_id);
print;
}' "$TEMPLATE" > "$out_file"
# Sanity check: the template authors might leave unresolved tokens by mistake.
if grep -q '__[A-Z_]\+__' "$out_file"; then
printf 'deploy: WARNING: unresolved __TOKEN__ placeholders remain in %s\n' \
"$out_file" >&2
fi
env_example="$REPO_DIR/.env.example"
if [ ! -e "$deploy_dir/.env" ] && [ -r "$env_example" ]; then
cp "$env_example" "$deploy_dir/.env"
env_seeded=1
else
env_seeded=0
fi
cat > "$deploy_dir/.deploy.meta" <<EOF
service=$REMOTE_REPO
service_number=$SERVICE_NUMBER
version=$VERSION
image=$IMAGE_REPO:$VERSION
host_port=$HOST_PORT
gitea_project_id=$GITEA_PROJECT_ID
gitea_owner=$REMOTE_OWNER
gitea_host=$REMOTE_HOST
include_paths_file=$(basename "$INCLUDE_PATHS_FILE")
generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
cat <<EOF
deploy: prepared $deploy_dir
service: $REMOTE_REPO (#${SERVICE_NUMBER})
image: $IMAGE_REPO:$VERSION
host port: $HOST_PORT (5000 + 10*${GITEA_PROJECT_ID} + ${SERVICE_NUMBER})
EOF
if [ "$env_seeded" = 1 ]; then
printf 'deploy: seeded .env from .env.example — EDIT IT before starting\n'
fi
cat <<EOF
Next steps:
cd "$deploy_dir"
\$EDITOR .env
docker compose pull
docker compose up -d
EOF

View File

@@ -0,0 +1,23 @@
# Generated by deploy/deploy.sh from this repo's deployment template.
# Internal port and mounts are service-specific; host port and image tag are
# filled in by the deployment helper.
services:
__SERVICE_NAME__:
image: __IMAGE__:__IMAGE_TAG__
container_name: __SERVICE_NAME__-__SERVICE_NUMBER__
restart: unless-stopped
env_file:
- .env
environment:
PORT: 8091
ports:
- "__HOST_PORT__:8091"
volumes:
- ./config:/app/config:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8091/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

3
deploy/include-paths.txt Normal file
View File

@@ -0,0 +1,3 @@
# Repo-relative files/directories to copy alongside docker-compose.yml into the
# generated deployment directory.
config