From 5dd0a3aeac7d4bd906da056ba09f09da88470343 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Aug 2026 22:56:46 +0200 Subject: [PATCH] Add Gitea Actions CI/CD to build, push, and deploy on main. Mirrors the airports runner flow: test, publish to the Gitea registry, then pull APP_IMAGE into the z00 compose stack. --- .env.example | 2 + .gitea/workflows/ci.yml | 132 +++++++++++++++++++++++++++++++++++ README.md | 22 ++++++ compose.z00.yaml | 41 +++++++++++ docker-compose.yml | 15 ++-- scripts/deploy-production.sh | 85 ++++++++++++++++++++++ scripts/setup-gitea-ci.sh | 80 +++++++++++++++++++++ 7 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 compose.z00.yaml create mode 100755 scripts/deploy-production.sh create mode 100755 scripts/setup-gitea-ci.sh diff --git a/.env.example b/.env.example index df0b154..22939c3 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8080 +# Production/CI: set to the Gitea registry image (local Compose builds andytranscribe-app:latest). +# APP_IMAGE=gitea.z00.nu/ben/andytranscribe:latest APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a8ee473 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,132 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +env: + REGISTRY: gitea.z00.nu + # Baked into the Vite client bundle for production WebSockets. + VITE_REVERB_HOST: reverb.transcribe.z00.nu + VITE_REVERB_PORT: "443" + VITE_REVERB_SCHEME: https + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + set -euo pipefail + HOST="${{ gitea.server_url }}" + HOST="${HOST#https://}" + HOST="${HOST#http://}" + git clone --depth 1 \ + "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \ + . + git fetch --depth 1 origin "${{ gitea.sha }}" + git checkout --force "${{ gitea.sha }}" + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Build frontend assets + run: | + set -euo pipefail + docker run --rm \ + -u "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -e VITE_APP_NAME=AndyTranscribe \ + -e VITE_REVERB_APP_KEY=andytranscribe-key \ + -e "VITE_REVERB_HOST=${VITE_REVERB_HOST}" \ + -e "VITE_REVERB_PORT=${VITE_REVERB_PORT}" \ + -e "VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}" \ + -v "$PWD:/app" \ + -w /app \ + node:22-alpine \ + sh -c "npm ci && npm run build" + + - name: Run tests + run: | + set -euo pipefail + cp .env.example .env + php artisan key:generate --force --no-interaction + php -d memory_limit=512M artisan test --compact + + - name: Validate compose + run: | + set -euo pipefail + APP_IMAGE="${REGISTRY}/$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]'):test" \ + APP_KEY="base64:dGVzdC1hcHAta2V5LWZvci1jaS1jb21wb3NlLXZhbGlkYXRpb24=" \ + docker compose -f docker-compose.yml -f compose.z00.yaml config --quiet + + build-and-push: + if: gitea.event_name != 'pull_request' + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + set -euo pipefail + HOST="${{ gitea.server_url }}" + HOST="${HOST#https://}" + HOST="${HOST#http://}" + git clone --depth 1 \ + "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \ + . + git fetch --depth 1 origin "${{ gitea.sha }}" + git checkout --force "${{ gitea.sha }}" + + - name: Build and push image + run: | + set -euo pipefail + REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')" + TAG_SHA="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}" + TAG_LATEST="${REGISTRY}/${REPO_LC}:latest" + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${REGISTRY}" -u "${{ gitea.actor }}" --password-stdin + docker build \ + --build-arg VITE_APP_NAME=AndyTranscribe \ + --build-arg VITE_REVERB_APP_KEY=andytranscribe-key \ + --build-arg "VITE_REVERB_HOST=${VITE_REVERB_HOST}" \ + --build-arg "VITE_REVERB_PORT=${VITE_REVERB_PORT}" \ + --build-arg "VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}" \ + -t "${TAG_SHA}" \ + -t "${TAG_LATEST}" \ + . + docker push "${TAG_SHA}" + docker push "${TAG_LATEST}" + + deploy: + if: gitea.ref == 'refs/heads/main' && gitea.event_name != 'pull_request' + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + set -euo pipefail + HOST="${{ gitea.server_url }}" + HOST="${HOST#https://}" + HOST="${HOST#http://}" + git clone --depth 1 \ + "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@${HOST}/${{ gitea.repository }}.git" \ + . + git fetch --depth 1 origin "${{ gitea.sha }}" + git checkout --force "${{ gitea.sha }}" + + - name: Deploy production + env: + DEPLOY_PATHS: ${{ secrets.DEPLOY_PATHS }} + run: | + set -euo pipefail + REPO_LC="$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')" + export APP_IMAGE="${REGISTRY}/${REPO_LC}:${{ gitea.sha }}" + chmod +x scripts/deploy-production.sh + if [ -z "${DEPLOY_PATHS:-}" ]; then + echo "DEPLOY_PATHS secret is not set; skipping deploy." + echo "Built image: ${APP_IMAGE}" + exit 0 + fi + ./scripts/deploy-production.sh diff --git a/README.md b/README.md index 64a6010..f0a9d49 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,27 @@ Then a normal `docker compose up -d` enables: - `queue:listen` so worker code picks up changes between jobs Open [http://localhost:8080](http://localhost:8080) as usual. After changing Composer packages, run `docker compose exec app composer install`. + +## CI/CD (Gitea Actions) + +On push to `main`, Gitea Actions (host runner on z00): + +1. Runs PHPUnit +2. Builds and pushes `gitea.z00.nu/ben/andytranscribe:` (+ `:latest`) +3. Deploys by pulling that image into `~/andyTranscibe` (`docker-compose.yml` + `compose.z00.yaml`) + +One-time server bootstrap (secrets + registry login): + +```bash +./scripts/setup-gitea-ci.sh +``` + +Manual deploy of an already-built tag: + +```bash +APP_IMAGE=gitea.z00.nu/ben/andytranscribe: DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh +``` + ## Services and ports | Service | Host port | Role | @@ -181,6 +202,7 @@ Edit `.env` before `docker compose up` when you need different ports or models: | Variable | Purpose | Default | | --- | --- | --- | | `APP_KEY` | Required Laravel encryption key | — | +| `APP_IMAGE` | Pre-built image for CI/prod deploys (omit locally) | `andytranscribe-app:latest` | | `APP_URL` | Public app URL | `http://localhost:8080` | | `APP_HOST_PORT` | Host port for the web app | `8080` | | `REVERB_HOST_PORT` | Host port for WebSockets | `8081` | diff --git a/compose.z00.yaml b/compose.z00.yaml new file mode 100644 index 0000000..02344ca --- /dev/null +++ b/compose.z00.yaml @@ -0,0 +1,41 @@ +# z00 production overlay for AndyTranscribe (behind Caddy Proxy Manager) +services: + app: + networks: + - default + - caddy + environment: + APP_ENV: production + APP_DEBUG: "false" + APP_URL: https://transcribe.z00.nu + TRUSTED_PROXIES: "*" + REVERB_HOST: reverb + REVERB_PORT: "8080" + REVERB_SCHEME: http + reverb: + networks: + - default + - caddy + environment: + APP_URL: https://transcribe.z00.nu + REVERB_HOST: reverb.transcribe.z00.nu + REVERB_PORT: "443" + REVERB_SCHEME: https + queue: + networks: + - default + environment: + APP_ENV: production + APP_DEBUG: "false" + APP_URL: https://transcribe.z00.nu + REVERB_HOST: reverb + REVERB_PORT: "8080" + REVERB_SCHEME: http + whisper: + networks: + - default + +networks: + caddy: + external: true + name: caddy-proxy-manager-test_caddy-test-network diff --git a/docker-compose.yml b/docker-compose.yml index 6010828..6057588 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,8 +39,14 @@ x-app-env: &app-env SEED_USER_EMAIL: ${SEED_USER_EMAIL:-demo@example.com} SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-password} +# Local: omit APP_IMAGE (builds andytranscribe-app:latest). +# CI/prod: set APP_IMAGE=gitea.z00.nu/ben/andytranscribe: and pull. +x-app-image: &app-image + image: ${APP_IMAGE:-andytranscribe-app:latest} + services: app: + <<: *app-image build: context: . dockerfile: Dockerfile @@ -50,7 +56,6 @@ services: VITE_REVERB_HOST: ${VITE_REVERB_HOST:-localhost} VITE_REVERB_PORT: ${REVERB_HOST_PORT:-8081} VITE_REVERB_SCHEME: ${VITE_REVERB_SCHEME:-http} - image: andytranscribe-app:latest container_name: andytranscribe-app ports: - "${APP_HOST_PORT:-8080}:80" @@ -72,11 +77,10 @@ services: condition: service_started restart: unless-stopped - # Shares andytranscribe-app:latest — do not declare build: here (avoids rebuilding 3×). + # Shares the app image — do not declare build: here (avoids rebuilding 3×). # `docker compose up --build` builds `app` first, then starts these with the tagged image. queue: - image: andytranscribe-app:latest - pull_policy: never + <<: *app-image container_name: andytranscribe-queue command: - php @@ -103,8 +107,7 @@ services: restart: unless-stopped reverb: - image: andytranscribe-app:latest - pull_policy: never + <<: *app-image container_name: andytranscribe-reverb command: - php diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100755 index 0000000..9ee1997 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Deploy a pre-built APP_IMAGE to one or more AndyTranscribe directories. + +Environment: + APP_IMAGE Required. e.g. gitea.z00.nu/ben/andytranscribe:abc1234 + DEPLOY_PATHS Comma-separated instance directories (default: current directory) + GIT_PULL When 1 (default), run git pull --ff-only in each directory first + +Usage: + APP_IMAGE=gitea.z00.nu/ben/andytranscribe:tag DEPLOY_PATHS=$HOME/andyTranscibe ./scripts/deploy-production.sh +EOF +} + +IMAGE="${APP_IMAGE:-${1:-}}" +if [[ -z "${IMAGE}" ]]; then + usage >&2 + exit 1 +fi + +PATHS_CSV="${DEPLOY_PATHS:-${2:-.}}" +GIT_PULL="${GIT_PULL:-1}" +IFS=',' read -r -a DIRS <<< "${PATHS_CSV}" + +for dir in "${DIRS[@]}"; do + dir="${dir#"${dir%%[![:space:]]*}"}" + dir="${dir%"${dir##*[![:space:]]}"}" + + if [[ ! -d "${dir}" ]]; then + echo "Missing instance directory: ${dir}" >&2 + exit 1 + fi + + name="$(basename "${dir}")" + echo "==> Deploying ${IMAGE} in ${dir}" + + ( + cd "${dir}" + + if [[ "${GIT_PULL}" == "1" ]] && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git pull --ff-only + fi + + export APP_IMAGE="${IMAGE}" + export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-${name,,}}" + + compose_files=(-f docker-compose.yml) + if [[ -f compose.z00.yaml ]]; then + compose_files+=(-f compose.z00.yaml) + fi + + if grep -q '^APP_IMAGE=' .env 2>/dev/null; then + sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${IMAGE}|" .env + else + printf '\nAPP_IMAGE=%s\n' "${IMAGE}" >> .env + fi + + docker compose "${compose_files[@]}" pull app queue reverb + docker compose "${compose_files[@]}" up -d + docker compose "${compose_files[@]}" ps + + app_port="$(awk -F= '/^APP_HOST_PORT=/ {print $2; exit}' .env 2>/dev/null || true)" + app_port="${app_port:-18080}" + health_url="http://127.0.0.1:${app_port}/up" + echo "waiting for ${health_url}" + ok=0 + for _ in $(seq 1 45); do + if curl -fsS "${health_url}" >/dev/null 2>&1; then + echo "healthy" + ok=1 + break + fi + sleep 2 + done + if [[ "${ok}" -ne 1 ]]; then + echo "ERROR: health check failed for ${name}" >&2 + exit 1 + fi + ) +done + +echo "Deploy complete: ${IMAGE}" diff --git a/scripts/setup-gitea-ci.sh b/scripts/setup-gitea-ci.sh new file mode 100755 index 0000000..1d8ded0 --- /dev/null +++ b/scripts/setup-gitea-ci.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Idempotent bootstrap for AndyTranscribe Gitea Actions secrets on z00. +# Requires an already-running Gitea + act_runner (see airports setup). +set -euo pipefail + +GITEA_DIR="${GITEA_DIR:-${HOME}/gitea}" +REPO_OWNER="${REPO_OWNER:-ben}" +REPO_NAME="${REPO_NAME:-AndyTranscribe}" +REGISTRY_HOST="${REGISTRY_HOST:-gitea.z00.nu}" +DEPLOY_PATH="${DEPLOY_PATH:-${HOME}/andyTranscibe}" +CREDENTIALS_FILE="${GITEA_DIR}/.credentials" + +if [[ ! -f "${CREDENTIALS_FILE}" ]]; then + echo "Missing ${CREDENTIALS_FILE}" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +source "${CREDENTIALS_FILE}" + +API="https://${REGISTRY_HOST}/api/v1" +AUTH=(-u "${ADMIN_USERNAME}:${ADMIN_PASSWORD}") + +if ! curl -fsS "${AUTH[@]}" "${API}/repos/${REPO_OWNER}/${REPO_NAME}" >/dev/null 2>&1; then + echo "Repository ${REPO_OWNER}/${REPO_NAME} not found on ${REGISTRY_HOST}" >&2 + exit 1 +fi + +CI_TOKEN="$( + docker exec -u git gitea gitea admin user generate-access-token \ + -u "${ADMIN_USERNAME}" \ + -t "ci-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \ + --scopes "write:package,read:package,write:repository,read:repository" \ + --raw +)" + +PULL_TOKEN="$( + docker exec -u git gitea gitea admin user generate-access-token \ + -u "${ADMIN_USERNAME}" \ + -t "pull-${REPO_NAME}-$(date +%Y%m%d%H%M%S)" \ + --scopes "read:package" \ + --raw +)" + +set_secret() { + local name="$1" + local value="$2" + local tmp + tmp="$(mktemp)" + python3 -c 'import json,sys; json.dump({"data": sys.argv[1]}, open(sys.argv[2], "w"))' "${value}" "${tmp}" + curl -fsS "${AUTH[@]}" -X PUT \ + "${API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/secrets/${name}" \ + -H "Content-Type: application/json" \ + --data-binary @"${tmp}" >/dev/null + rm -f "${tmp}" +} + +set_secret "REGISTRY_TOKEN" "${CI_TOKEN}" +set_secret "DEPLOY_PATHS" "${DEPLOY_PATH}" + +printf '%s' "${PULL_TOKEN}" | docker login "${REGISTRY_HOST}" -u "${ADMIN_USERNAME}" --password-stdin + +REPO_LC="$(echo "${REPO_OWNER}/${REPO_NAME}" | tr '[:upper:]' '[:lower:]')" +if [[ -f "${DEPLOY_PATH}/.env" ]]; then + if grep -q '^APP_IMAGE=' "${DEPLOY_PATH}/.env"; then + sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${REGISTRY_HOST}/${REPO_LC}:latest|" "${DEPLOY_PATH}/.env" + else + printf '\nAPP_IMAGE=%s/%s:latest\n' "${REGISTRY_HOST}" "${REPO_LC}" >> "${DEPLOY_PATH}/.env" + fi +fi + +# Ensure the existing host runner is up (shared with airports). +if systemctl --user is-enabled gitea-act-runner.service >/dev/null 2>&1; then + systemctl --user restart gitea-act-runner.service || true + systemctl --user --no-pager --lines=5 status gitea-act-runner.service || true +fi + +echo "Gitea CI secrets configured for ${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}" +echo "Deploy path: ${DEPLOY_PATH}" +echo "Push to main to build, push the image, and deploy."