ci(project): add Gitea Actions deploy workflow and docs
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
+182
@@ -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 <REGISTRATION_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.
|
||||||
Reference in New Issue
Block a user