feat(deploy): switch to Oracle MySQL, separate CI runner, docker-compose deploy

- Prisma schema: sqlite → mysql provider
- Migration SQL rewritten as MySQL DDL (utf8mb4, DATETIME(3), AUTO_INCREMENT)
- Add migration_lock.toml for mysql provider
- Dockerfile.backend: drop node:22/sqlite deps, use node:20-slim
- entrypoint.sh: replace SQLite first-run logic with prisma migrate deploy + db seed
- docker-compose.prod.yml: production compose for /opt/webapps/okr on web VPS
  - reads DB creds from /opt/webapps/webapp-mysql.env
  - reads app secrets from /opt/webapps/okr/.env.app (written by CI)
  - port 80 (frontend), no conflict with Gitea 3000/Vault 8200
- ci.yml deploy-okr: moves from ubuntu-latest (web VPS) to ci-runner (161.33.149.243)
  - builds images on CI runner VPS (no heavy build on web/Gitea VPS)
  - transfers images via docker save | gzip | ssh | docker load
  - deploys via SSH + docker compose up on web VPS
- scripts/setup-ci-runner.sh: one-time setup script for CI runner VPS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-01 16:37:56 +09:00
co-authored by Claude Sonnet 4.6
parent 719f1147d1
commit 9229fee656
8 changed files with 284 additions and 122 deletions
+75 -50
View File
@@ -1,13 +1,11 @@
name: CASAN CI Gate name: CASAN CI Gate
# Runs on every push/PR to catch regressions (H3) and validate security controls (H4/H5).
on: on:
push: push:
branches: [main, develop, "feature/**"] branches: [main, develop, "feature/**"]
pull_request: pull_request:
branches: [main] branches: [main]
# Cancel in-flight runs of the same branch when a newer push arrives.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -19,6 +17,7 @@ concurrency:
jobs: jobs:
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Job 1 — Frontend unit tests (fast gate, ~1 min) # Job 1 — Frontend unit tests (fast gate, ~1 min)
# Runs on: web/Gitea VPS runner (161.33.139.73) — light job, no Docker build
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
frontend-tests: frontend-tests:
name: "Frontend Tests (H3 gate)" name: "Frontend Tests (H3 gate)"
@@ -45,6 +44,8 @@ jobs:
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Job 2 — CASAN Security Gate + Vault KMS signing (H4/H5/H2/H6/H7) # Job 2 — CASAN Security Gate + Vault KMS signing (H4/H5/H2/H6/H7)
# Runs on: web/Gitea VPS runner (161.33.139.73) — after frontend-tests to
# prevent OOM (1 GB RAM VPS, sequential jobs via capacity: 1)
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
security-gate: security-gate:
name: "CASAN Security Gate + Vault KMS (H4/H5)" name: "CASAN Security Gate + Vault KMS (H4/H5)"
@@ -116,15 +117,33 @@ jobs:
retention-days: 14 retention-days: 14
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# Job 3 — Deploy OKR web app (main branch only) # Job 3 — Build Docker images + deploy to /opt/webapps/okr (main only)
# #
# okr-backend — NestJS + Prisma + SQLite, port 3001 (internal) # Runs on: DEDICATED CI RUNNER (161.33.149.243, label: ci-runner)
# okr-frontend — nginx + React SPA, port 80 (public) # Heavy Docker builds do NOT run on the web/Gitea VPS.
# nginx proxies /api/v1/* → okr-backend:3001 #
# Architecture:
# CI runner (161.33.149.243) Web VPS (161.33.139.73)
# ───────────────────────── ────────────────────────────────
# docker build okr-backend /opt/webapps/okr/
# docker build okr-frontend ──► docker-compose.yml
# docker save | gzip | ssh ──► docker load
# docker compose up -d
#
# DB: Oracle MySQL HeatWave (10.0.1.254:3306, webapp_db)
# Credentials live on web VPS: /opt/webapps/webapp-mysql.env
#
# Required Gitea secrets:
# DEPLOY_SSH_KEY — ed25519 private key for ubuntu@161.33.139.73
# JWT_SECRET — app JWT signing secret
#
# One-time CI runner setup:
# ssh ubuntu@161.33.149.243
# RUNNER_TOKEN=<from Gitea admin> bash AINative_OKR_CASAN5/scripts/setup-ci-runner.sh
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
deploy-okr: deploy-okr:
name: "Deploy OKR → port 80 (H3 CI gate)" name: "Build & Deploy OKR → /opt/webapps/okr"
runs-on: ubuntu-latest runs-on: [ci-runner]
needs: [frontend-tests, security-gate] needs: [frontend-tests, security-gate]
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
defaults: defaults:
@@ -132,20 +151,25 @@ jobs:
working-directory: AINative_OKR_CASAN5 working-directory: AINative_OKR_CASAN5
env: env:
JWT_SECRET: ${{ secrets.JWT_SECRET }} JWT_SECRET: ${{ secrets.JWT_SECRET }}
FRONTEND_ORIGIN: "http://161.33.139.73" WEB_VPS: "ubuntu@161.33.139.73"
APP_DIR: "/opt/webapps/okr"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build backend image (node:22-slim — required for node:sqlite) - name: Setup SSH for web VPS deploy
run: | run: |
docker build \ mkdir -p ~/.ssh
-t okr-backend:latest \ printf '%s' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
-f Dockerfile.backend \ chmod 600 ~/.ssh/deploy_key
. ssh-keyscan -H 161.33.139.73 >> ~/.ssh/known_hosts 2>/dev/null
echo "SSH ready"
- name: Build frontend image (nginx + React SPA) - name: Build backend image (node:20-slim + Prisma MySQL)
run: docker build -t okr-backend:latest -f Dockerfile.backend .
- name: Build frontend image (nginx + React SPA, VITE_API_BASE_URL=/api/v1)
run: | run: |
docker build \ docker build \
--build-arg VITE_API_BASE_URL=/api/v1 \ --build-arg VITE_API_BASE_URL=/api/v1 \
@@ -153,46 +177,47 @@ jobs:
-f Dockerfile.frontend \ -f Dockerfile.frontend \
. .
- name: Create network + persistent volume - name: Transfer images to web VPS
run: | run: |
docker network create okr-net 2>/dev/null || true echo "Streaming images to web VPS (this may take ~1 min)..."
docker volume create okr-db 2>/dev/null || true docker save okr-backend:latest okr-frontend:latest | \
gzip | \
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
'docker load'
- name: Deploy backend (port 3001, internal only) - name: Prepare app directory + docker-compose on web VPS
run: | run: |
docker rm -f okr-backend 2>/dev/null || true ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
docker run -d \ "mkdir -p $APP_DIR"
--name okr-backend \ scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
--network okr-net \ docker-compose.prod.yml $WEB_VPS:$APP_DIR/docker-compose.yml
-e PORT=3001 \
-e DATABASE_URL=file:/data/okr.db \
-e JWT_SECRET="${JWT_SECRET}" \
-e FRONTEND_ORIGIN="${FRONTEND_ORIGIN}" \
-e NODE_ENV=production \
-v okr-db:/data \
--restart unless-stopped \
okr-backend:latest
- name: Deploy frontend (port 80, public) - name: Write app secrets on web VPS
run: | run: |
docker rm -f okr-frontend 2>/dev/null || true ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
docker run -d \ "printf 'JWT_SECRET=%s\nFRONTEND_ORIGIN=http://161.33.139.73\n' '${JWT_SECRET}' \
--name okr-frontend \ > $APP_DIR/.env.app && chmod 600 $APP_DIR/.env.app"
--network okr-net \
-p 80:80 \ - name: Deploy containers on web VPS
--restart unless-stopped \ run: |
okr-frontend:latest ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \
"cd $APP_DIR && docker compose up -d --remove-orphans --pull never"
- name: Health check - name: Health check
run: | run: |
echo "Waiting 20 s for containers to initialise..." echo "Waiting 30s for containers to initialise (includes Prisma migrate + seed)..."
sleep 20 sleep 30
echo "=== Running containers ===" ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS '
docker ps --filter "name=okr" --format "{{.Names}}\t{{.Status}}\t{{.Ports}}" echo "=== Containers ===" &&
if docker ps --filter "name=okr-frontend" --filter "status=running" | grep -q okr-frontend; then docker compose -f /opt/webapps/okr/docker-compose.yml ps &&
echo "DEPLOY_OK frontend=http://161.33.139.73" echo "" &&
else echo "=== HTTP check ===" &&
echo "DEPLOY_WARN containers not fully running — dumping logs" if curl -fsS -o /dev/null -w "HTTP %{http_code}" http://localhost; then
docker logs okr-frontend --tail 30 || true echo ""
docker logs okr-backend --tail 30 || true echo "DEPLOY_OK http://161.33.139.73"
fi else
echo "DEPLOY_WARN — backend logs:"
docker logs okr-backend --tail 30 || true
docker logs okr-frontend --tail 20 || true
fi
'
+2 -10
View File
@@ -1,5 +1,5 @@
# ─── Stage 1: Build ────────────────────────────────────────────────────────── # ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:22-slim AS builder FROM node:20-slim AS builder
WORKDIR /app WORKDIR /app
@@ -15,13 +15,10 @@ RUN npm ci
COPY backend/src ./backend/src COPY backend/src ./backend/src
COPY backend/tsconfig*.json ./backend/ COPY backend/tsconfig*.json ./backend/
# Generate Prisma client + compile TypeScript
RUN cd backend && npx prisma generate && npx tsc -p tsconfig.build.json RUN cd backend && npx prisma generate && npx tsc -p tsconfig.build.json
# ─── Stage 2: Runtime ──────────────────────────────────────────────────────── # ─── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM node:22-slim AS runtime FROM node:20-slim AS runtime
# node:sqlite (setup-sqlite.mjs) requires Node 22 — this image satisfies that.
WORKDIR /app WORKDIR /app
@@ -32,24 +29,19 @@ COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/ COPY frontend/package.json ./frontend/
COPY backend/prisma ./backend/prisma COPY backend/prisma ./backend/prisma
# All deps (including devDeps) so that tsx (seed) and prisma CLI are available.
RUN npm ci --ignore-scripts RUN npm ci --ignore-scripts
# Generate Prisma client in runtime image (CWD resolves schema at backend/prisma/schema.prisma)
RUN cd backend && npx prisma generate RUN cd backend && npx prisma generate
COPY --from=builder /app/backend/dist ./backend/dist COPY --from=builder /app/backend/dist ./backend/dist
COPY backend/scripts ./backend/scripts
COPY backend/entrypoint.sh /entrypoint.sh COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
WORKDIR /app/backend WORKDIR /app/backend
VOLUME ["/data"]
EXPOSE 3001 EXPOSE 3001
ENV PORT=3001 ENV PORT=3001
ENV DATABASE_URL=file:/data/okr.db
ENV NODE_ENV=production ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+7 -12
View File
@@ -1,21 +1,16 @@
#!/bin/sh #!/bin/sh
# OKR backend container entrypoint. # OKR backend container entrypoint for MySQL.
# - First run: creates SQLite schema via setup-sqlite.mjs + seeds initial data. # - Applies pending Prisma migrations (idempotent).
# - Subsequent runs: DB already exists, skip init. # - Seeds initial data via upsert (safe to run on every start).
# WORKDIR expected: /app/backend (set in Dockerfile)
set -e set -e
# Hoisted node_modules/.bin (workspace root) must be in PATH for tsx + prisma CLI
export PATH="/app/node_modules/.bin:$PATH" export PATH="/app/node_modules/.bin:$PATH"
mkdir -p /data echo "[OKR] Applying database migrations..."
npx prisma migrate deploy
if [ ! -f "/data/okr.db" ]; then echo "[OKR] Seeding database..."
echo "[OKR] First run — initializing database at /data/okr.db" npx prisma db seed
node scripts/setup-sqlite.mjs
npx prisma db seed
echo "[OKR] Database initialized"
fi
echo "[OKR] Starting backend on port ${PORT:-3001}" echo "[OKR] Starting backend on port ${PORT:-3001}"
exec node dist/main.js exec node dist/main.js
@@ -1,53 +1,65 @@
-- Initial OKR SQLite schema generated from prisma/schema.prisma via prisma migrate diff. -- MySQL initial schema for OKR application
CREATE TABLE "User" ( CREATE TABLE `User` (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `id` INT NOT NULL AUTO_INCREMENT,
"name" TEXT NOT NULL, `name` VARCHAR(191) NOT NULL,
"username" TEXT NOT NULL, `username` VARCHAR(191) NOT NULL,
"email" TEXT NOT NULL, `email` VARCHAR(191) NOT NULL,
"passwordHash" TEXT NOT NULL, `passwordHash` VARCHAR(191) NOT NULL,
"role" TEXT NOT NULL, `role` VARCHAR(191) NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
"updatedAt" DATETIME NOT NULL `updatedAt` DATETIME(3) NOT NULL,
); PRIMARY KEY (`id`),
UNIQUE INDEX `User_username_key`(`username`),
UNIQUE INDEX `User_email_key`(`email`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "Objective" ( CREATE TABLE `Objective` (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `id` INT NOT NULL AUTO_INCREMENT,
"title" TEXT NOT NULL, `title` VARCHAR(191) NOT NULL,
"description" TEXT, `description` VARCHAR(191) NULL,
"ownerId" INTEGER NOT NULL, `ownerId` INT NOT NULL,
"quarter" TEXT NOT NULL, `quarter` VARCHAR(191) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'NOT_STARTED', `status` VARCHAR(191) NOT NULL DEFAULT 'NOT_STARTED',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
"updatedAt" DATETIME NOT NULL, `updatedAt` DATETIME(3) NOT NULL,
CONSTRAINT "Objective_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE INDEX `Objective_ownerId_idx`(`ownerId`),
); INDEX `Objective_quarter_idx`(`quarter`),
INDEX `Objective_status_idx`(`status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "KeyResult" ( CREATE TABLE `KeyResult` (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `id` INT NOT NULL AUTO_INCREMENT,
"objectiveId" INTEGER NOT NULL, `objectiveId` INT NOT NULL,
"title" TEXT NOT NULL, `title` VARCHAR(191) NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0, `progress` INT NOT NULL DEFAULT 0,
"startValue" INTEGER NOT NULL, `startValue` INT NOT NULL,
"targetValue" INTEGER NOT NULL, `targetValue` INT NOT NULL,
"deadline" DATETIME NOT NULL, `deadline` DATETIME(3) NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
"updatedAt" DATETIME NOT NULL, `updatedAt` DATETIME(3) NOT NULL,
CONSTRAINT "KeyResult_objectiveId_fkey" FOREIGN KEY ("objectiveId") REFERENCES "Objective" ("id") ON DELETE CASCADE ON UPDATE CASCADE INDEX `KeyResult_objectiveId_idx`(`objectiveId`),
); PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE "ProgressUpdate" ( CREATE TABLE `ProgressUpdate` (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, `id` INT NOT NULL AUTO_INCREMENT,
"keyResultId" INTEGER NOT NULL, `keyResultId` INT NOT NULL,
"progress" INTEGER NOT NULL, `progress` INT NOT NULL,
"comment" TEXT, `comment` VARCHAR(191) NULL,
"createdById" INTEGER NOT NULL, `createdById` INT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT "ProgressUpdate_keyResultId_fkey" FOREIGN KEY ("keyResultId") REFERENCES "KeyResult" ("id") ON DELETE CASCADE ON UPDATE CASCADE PRIMARY KEY (`id`)
); ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); ALTER TABLE `Objective` ADD CONSTRAINT `Objective_ownerId_fkey`
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`)
CREATE INDEX "Objective_ownerId_idx" ON "Objective"("ownerId"); ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "Objective_quarter_idx" ON "Objective"("quarter");
CREATE INDEX "Objective_status_idx" ON "Objective"("status"); ALTER TABLE `KeyResult` ADD CONSTRAINT `KeyResult_objectiveId_fkey`
CREATE INDEX "KeyResult_objectiveId_idx" ON "KeyResult"("objectiveId"); FOREIGN KEY (`objectiveId`) REFERENCES `Objective`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `ProgressUpdate` ADD CONSTRAINT `ProgressUpdate_keyResultId_fkey`
FOREIGN KEY (`keyResultId`) REFERENCES `KeyResult`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
@@ -3,7 +3,7 @@ generator client {
} }
datasource db { datasource db {
provider = "sqlite" provider = "mysql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
@@ -0,0 +1,42 @@
version: '3.8'
# Production deployment for VPS: /opt/webapps/okr/
# DB credentials come from /opt/webapps/webapp-mysql.env (managed on VPS, not in repo).
# App secrets (JWT_SECRET) come from /opt/webapps/okr/.env.app (written by CI deploy step).
# Ports in use on this VPS — DO NOT conflict:
# 3000 = Gitea HTTP, 2222 = Gitea SSH, 8200 = Vault
services:
okr-backend:
image: okr-backend:latest
container_name: okr-backend
restart: unless-stopped
env_file:
- /opt/webapps/webapp-mysql.env # DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DATABASE_URL
- /opt/webapps/okr/.env.app # JWT_SECRET, FRONTEND_ORIGIN
environment:
PORT: "3001"
NODE_ENV: production
mem_limit: 256m
cpus: "0.5"
networks:
- okr-net
expose:
- "3001"
okr-frontend:
image: okr-frontend:latest
container_name: okr-frontend
restart: unless-stopped
ports:
- "80:80"
mem_limit: 64m
cpus: "0.25"
networks:
- okr-net
depends_on:
- okr-backend
networks:
okr-net:
driver: bridge
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Setup act_runner on the CI runner VPS (161.33.149.243).
#
# Prerequisites (run on CI runner VPS as ubuntu):
# 1. Get a runner registration token from Gitea:
# http://161.33.139.73:3000 → Site Administration → Runners → "Create Runner"
# 2. Generate a deploy SSH key for accessing the web VPS:
# ssh-keygen -t ed25519 -f /tmp/deploy_key -N ""
# ssh-copy-id -i /tmp/deploy_key.pub ubuntu@161.33.139.73
# Add /tmp/deploy_key (private) as Gitea secret: DEPLOY_SSH_KEY
# rm /tmp/deploy_key
#
# Usage:
# RUNNER_TOKEN=<token-from-gitea> bash setup-ci-runner.sh
#
set -euo pipefail
GITEA_URL="http://161.33.139.73:3000"
RUNNER_NAME="casan-ci-runner"
RUNNER_VERSION="v0.2.12"
INSTALL_DIR="/opt/act-runner"
if [[ -z "${RUNNER_TOKEN:-}" ]]; then
echo "ERROR: RUNNER_TOKEN env var is required."
echo " Get it from: $GITEA_URL → Site Administration → Runners → Create Runner"
exit 1
fi
echo "=== Installing act_runner $RUNNER_VERSION ==="
sudo mkdir -p "$INSTALL_DIR"
sudo curl -fsSL \
"https://gitea.com/gitea/act_runner/releases/download/${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64" \
-o "$INSTALL_DIR/act_runner"
sudo chmod +x "$INSTALL_DIR/act_runner"
echo "=== Writing runner config ==="
sudo tee "$INSTALL_DIR/config.yaml" > /dev/null <<'CONFIG'
log:
level: info
runner:
name: "casan-ci-runner"
capacity: 1
labels:
- "ci-runner:docker://catthehacker/ubuntu:act-22.04"
fetch_interval: 5s
fetch_timeout: 60s
container:
# host network so the job container can reach Gitea at 161.33.139.73:3000
network: host
# 2 GB RAM available on CI runner — builds need up to 1.5 GB
options: "--memory 1536m --cpus 1.5"
valid_volumes:
- "**"
CONFIG
echo "=== Registering runner with Gitea ==="
cd "$INSTALL_DIR"
sudo ./act_runner register \
--instance "$GITEA_URL" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--no-interactive
echo "=== Installing systemd service ==="
sudo tee /etc/systemd/system/act-runner.service > /dev/null <<'SERVICE'
[Unit]
Description=Gitea act_runner (CI builds)
After=docker.service
Requires=docker.service
[Service]
User=ubuntu
Group=docker
WorkingDirectory=/opt/act-runner
ExecStart=/opt/act-runner/act_runner daemon --config /opt/act-runner/config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SERVICE
sudo systemctl daemon-reload
sudo systemctl enable act-runner
sudo systemctl start act-runner
echo ""
echo "=== CI runner setup complete ==="
echo "Check status: sudo systemctl status act-runner"
echo "View logs: sudo journalctl -u act-runner -f"
echo "Verify in Gitea: $GITEA_URL/-/admin/runners"