From 437b66e73a16904de0861a621c9e40eeee471fd7 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 12:15:33 +0300 Subject: [PATCH] ci(project): add Gitea Actions deploy workflow and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy to the production host on push to master via a self-hosted act_runner (host/shell executor): git fetch + reset --hard in /opt/tanabata, then docker compose up -d --build. Shell-only steps, so the host needs just git and docker — no node, no rsync. docs/DEPLOY.md covers the one-time setup: what a runner is, the runner user, cloning to /opt/tanabata with a read-only deploy key, registering act_runner with the host label, and the host .env. Notes the security reason to scope the runner to this repository. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/deploy.yml | 44 +++++++++ docs/DEPLOY.md | 182 ++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 docs/DEPLOY.md diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..664c166 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,44 @@ +name: deploy + +# Build the image and (re)start the compose stack on the production host +# whenever master moves. Also runnable manually from the Gitea Actions tab. +on: + push: + branches: [master] + workflow_dispatch: {} + +# One deploy at a time; queue rather than cancel an in-flight run. +concurrency: + group: deploy-prod + cancel-in-progress: false + +jobs: + deploy: + # Self-hosted act_runner registered on the prod host with the "host" label + # (shell executor), so the job uses the host's git + Docker daemon and the + # existing clone in /opt/tanabata. See docs/DEPLOY.md for runner setup. + # + # Only shell steps here (no `uses:` actions), so the host needs git + docker + # and nothing else — no node, no rsync. + runs-on: host + + env: + DEPLOY_DIR: /opt/tanabata + + steps: + - name: Pull latest master + # DEPLOY_DIR is a git clone set up once at deploy time. reset --hard + # makes it match origin exactly; .env is untracked (.gitignore) so it + # is never touched. + run: | + cd "$DEPLOY_DIR" + git fetch --prune origin + git reset --hard origin/master + + - name: Build image and start the stack + working-directory: /opt/tanabata + # .env must already exist in DEPLOY_DIR on the host (secrets + DB mode). + run: docker compose up -d --build --remove-orphans + + - name: Prune dangling build layers + run: docker image prune -f diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..1b9319f --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,182 @@ +# Deployment (Gitea Actions → host) + +Tanabata is deployed by a [Gitea Actions](https://docs.gitea.com/usage/actions/overview) +workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that +runs on the **production host itself**. On every push to `master` it updates the +git clone in `/opt/tanabata` and runs `docker compose up -d --build` there, so the +image is built from the freshly-pushed code and the stack is restarted. + +``` +push master ──> Gitea (container) ──> act_runner (host, "host" label) + │ git fetch + reset --hard (in /opt/tanabata) + └ docker compose up -d --build +``` + +The Gitea server runs in a container, but the **runner runs directly on the host** +(shell executor) so it can use the host's git, the host Docker daemon, and the +clone in `/opt/tanabata`. Nothing needs a registry — the host builds the image +locally. The workflow uses only shell steps, so the host needs just **git** and +**docker** (no node, no rsync). + +## What is a runner? + +Gitea (like GitHub) only *coordinates* CI: it stores the workflow, queues jobs, +and shows logs. It does **not** execute anything itself. A **runner** is a +separate agent program that polls Gitea for queued jobs, runs the steps on a +machine you control, and reports results back. + +Gitea's official runner is **act_runner** (a single Go binary; it uses the +`act` engine to interpret workflow YAML). One act_runner process can serve many +repos. Each runner advertises one or more **labels**, and a job's `runs-on:` +picks a runner by label. A label also decides *how* a job runs — the **executor**: + +- **docker executor** — each job runs in a fresh container from an image (e.g. + `node:20-bookworm`). Isolated and reproducible; the usual default. Label form + at registration: `ubuntu:docker://node:20-bookworm`. +- **host / shell executor** — the job runs directly on the host as the runner's + user, using host-installed tools. Label form: `host:host`. This is what we use, + because the deploy needs the host's Docker daemon and `/opt/tanabata`. + +So `runs-on: host` in the workflow ⇒ "run this job on a runner that registered a +`host` label" ⇒ our shell executor on the prod box. + +## One-time setup + +### 1. Enable Actions in Gitea + +Gitea 1.21+ has Actions on by default. Otherwise add to `app.ini` and restart: + +```ini +[actions] +ENABLED = true +``` + +### 2. A runner user on the host + +Pick (or create) the Linux user the runner runs as. It must be able to use Docker +and own the deploy dir — so the workflow needs no `sudo`: + +```bash +sudo useradd -r -m -d /home/gitea-runner gitea-runner # or reuse an existing user +sudo usermod -aG docker gitea-runner # host Docker access +``` + +The host needs `git` and a Docker engine with the Compose plugin: + +```bash +sudo apt install -y git docker.io docker-compose-plugin # Debian/Ubuntu +``` + +### 3. Clone the repo to /opt/tanabata once + +The workflow only does `git fetch` + `reset --hard`, so the clone (and its auth) +is established here, once. Use a **read-only deploy key** so the host never holds +write credentials: + +```bash +# As the runner user, create a key and add the PUBLIC half to the repo in Gitea: +# Repo → Settings → Deploy Keys → Add (read-only) +sudo -u gitea-runner ssh-keygen -t ed25519 -f /home/gitea-runner/.ssh/tanabata_deploy -N '' + +# Clone with that key (SSH URL of your Gitea repo): +sudo -u gitea-runner GIT_SSH_COMMAND='ssh -i /home/gitea-runner/.ssh/tanabata_deploy' \ + git clone git@gitea.example.com:you/tanabata.git /opt/tanabata +sudo chown -R gitea-runner:gitea-runner /opt/tanabata +``` + +> HTTPS works too — clone with a URL that carries a read-only token. SSH deploy +> keys are the cleaner, per-repo, read-only option. + +After cloning, recurring `git fetch` reuses the remote + key stored in +`/opt/tanabata/.git/config`, so the runner itself needs no standing credentials. + +### 4. Register and run act_runner on the host + +Get a registration token in Gitea. **Where you create it sets the runner's +scope** (and `--name` is only a display label, unrelated to scope): + +- **Repository** (Tanabata repo → Settings → Actions → Runners) → serves only + this repo. **Use this.** +- Organization → all repos in the org; Site (admin) → all repos on the instance. + +> Security: this runner is a host/shell executor with access to the Docker +> socket — effectively root on the host. Register it at the **repository** level +> so only Tanabata's workflows can run on your prod server; a site-wide runner +> would let any repo's workflow execute arbitrary commands here. + +Then, as the runner user: + +```bash +# Download act_runner: https://gitea.com/gitea/act_runner/releases +act_runner register --no-interactive \ + --instance https://gitea.example.com \ + --token \ + --name prod-host \ + --labels host:host # <-- maps `runs-on: host` to the shell executor + +# Run it (use a systemd unit in production so it survives reboots): +act_runner daemon +``` + +`--labels host:host` is what makes jobs run **on the host** instead of in a +container. The instance URL must be reachable from the host (Gitea's published +port / domain — not the in-container address). Registration writes a `.runner` +file (the runner's credentials) in the working directory. + +Minimal systemd unit (`/etc/systemd/system/act_runner.service`): + +```ini +[Unit] +Description=Gitea act_runner +After=docker.service +Requires=docker.service + +[Service] +User=gitea-runner +WorkingDirectory=/home/gitea-runner +ExecStart=/usr/local/bin/act_runner daemon +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable --now act_runner +``` + +### 5. Create /opt/tanabata/.env (secrets) + +The workflow **never** writes `.env` — it lives on the host and holds the real +secrets and the chosen DB mode. `.env` is git-ignored, so `git reset --hard` +leaves it untouched. Create it once: + +```bash +cd /opt/tanabata +sudo -u gitea-runner cp .env.example .env +sudo -u gitea-runner $EDITOR .env # set JWT_SECRET, ADMIN_PASSWORD, DATABASE_URL, etc. +``` + +See [`.env.example`](../.env.example) for every variable. For the bundled +Postgres keep `COMPOSE_PROFILES=with-db`; to use a Postgres already on the host, +set it empty and point `DATABASE_URL` at `host.docker.internal`. + +> Data lives in named Docker volumes by default (or the `*_DIR` host paths you +> set in `.env`, e.g. `/var/lib/tanabata/...`) — **not** in `/opt/tanabata`. So +> `git reset --hard` on the code dir never touches your data. + +## Deploying + +Push to `master` (or hit **Run workflow** on the Actions tab). Watch progress +under the repo's **Actions** tab. The first build pulls the Node/Go base images +and takes a few minutes; later builds reuse the host's layer cache. + +## Notes / alternatives + +- **Docker-executor runner instead of host.** If you'd rather the runner itself + run in a container, register with a Docker label and bind-mount + `/var/run/docker.sock` and `/opt/tanabata` into the job (act_runner + `config.yaml` → `container.valid_volumes`), then change `runs-on` accordingly. + The host executor above is simpler for host deploys. +- **Zero-downtime** isn't attempted: `compose up` recreates changed containers. + For a single-node setup the brief restart is usually fine.