From 4ba757256583f1e2be68d86b8ce594916e9d90f2 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Wed, 29 Apr 2026 12:44:39 +0200 Subject: [PATCH] ci(deploy): switch to libretech/runner-image:v1 and consolidate The deploy workflow is now a single job that builds, pushes, and deploys in one runner. Tag computation moved to docker/metadata-action, the per-deploy .env perl rewrite is gone (host pins LIBRENOTES_IMAGE once; main pushes update :main rolling, releases pin to :vX.Y.Z by manual edit), and both jobs run in our bespoke runner image whose runner user already has socket access via group membership. ci.yml moves to the same image so go/make/node are all available without per-step apt installs. Drops compose.prod.yaml (unused, redundant with compose.netcup.yaml). --- .env.netcup.example | 8 ++- .gitea/workflows/ci.yml | 6 +- .gitea/workflows/deploy.yml | 127 +++++++++++++----------------------- CLAUDE.md | 4 +- compose.prod.yaml | 45 ------------- docs/operations.md | 32 ++++----- 6 files changed, 74 insertions(+), 148 deletions(-) delete mode 100644 compose.prod.yaml diff --git a/.env.netcup.example b/.env.netcup.example index f2712e4..b31957c 100644 --- a/.env.netcup.example +++ b/.env.netcup.example @@ -1,9 +1,11 @@ # Server-side .env for netcup deploy. Copy to /srv/librenotes/.env on the host. # Used by: docker compose -f compose.yaml -f compose.netcup.yaml up -d -# Image to pull. Main pushes update :main; tag pushes pin to immutable -# tags such as :v0.1.0. The deploy workflow rewrites this line on tag -# pushes; rollback is `perl -i -pe 's|...|=...:vX.Y.Z|' .env && up -d`. +# Image to pull. The default `:main` rolls forward — main pushes +# rebuild and push the same tag, so `compose pull` picks up the new +# digest without rewriting this file. To pin a release (or roll back), +# edit this line to an immutable tag such as :v0.1.0 and re-run +# `docker compose -f compose.yaml -f compose.netcup.yaml pull && up -d`. LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:main # Public origin (caddy reverse-proxies to librenotes:8080) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a3f0894..46c4703 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -9,10 +9,10 @@ on: jobs: ci: runs-on: ubuntu-latest - # Pin image: needs make (not in node:20-bookworm). runner-latest - # bundles make, git, curl + node for setup-go. + # Bespoke runner image (Ubuntu 24.04 + make + git + node + go via + # actions/setup-go). See git.librete.ch/libretech/runner-image. container: - image: catthehacker/ubuntu:runner-latest + image: git.librete.ch/libretech/runner-image:v1 timeout-minutes: 5 steps: - name: Checkout diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2895648..ca7346c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,124 +6,91 @@ on: tags: ["v*"] # Required repository secrets: -# REGISTRY registry hostname, e.g. git.librete.ch -# REGISTRY_USER robot account or PAT username -# REGISTRY_PASS robot/PAT token with package:write -# DEPLOY_HOST deployment SSH target, e.g. root@netcup -# DEPLOY_KEY private SSH key (PEM, no passphrase) -# DEPLOY_PATH remote stack directory, e.g. /srv/librenotes -# HEALTH_URL public URL to verify post-deploy, e.g. -# https://ln.cloud.librete.ch/healthz +# REGISTRY registry hostname (git.librete.ch) +# REGISTRY_USER robot account or PAT username (libretech-bot) +# REGISTRY_PASS PAT with write:package +# DEPLOY_HOST SSH target, e.g. root@cloud.librete.ch +# DEPLOY_KEY passphrase-less private key (PEM) +# DEPLOY_PATH remote stack dir (/srv/librenotes) +# HEALTH_URL https://ln.cloud.librete.ch/healthz # # Required repository variable: # DEPLOY_ENABLED set to "true" to enable the workflow # -# Image path: ${REGISTRY}/public/librenotes (matches Gitea owner/repo). -# Main pushes publish :main and :. Tag pushes publish : and -# :latest, then pin LIBRENOTES_IMAGE on the host to the immutable tag -# so rollback is just `perl -i -pe 's|^LIBRENOTES_IMAGE=.*|...=...:vX.Y.Z|' .env` -# followed by `docker compose ... up -d`. +# Image: ${REGISTRY}/public/librenotes +# main pushes → :main + : +# tag pushes → : + :latest +# +# The host's /srv/librenotes/.env pins LIBRENOTES_IMAGE once +# (e.g. =git.librete.ch/public/librenotes:main). Main pushes +# update :main rolling so a `compose pull` picks up the new image +# without rewriting any file. Tag pin / rollback is a manual edit +# of LIBRENOTES_IMAGE in .env followed by `compose pull && up -d`. jobs: - build: + deploy: runs-on: ubuntu-latest - # Gitea Actions: pin image so docker CLI is present. The runner - # already bind-mounts /var/run/docker.sock into job containers - # automatically (because it has the socket mounted itself); adding - # `volumes:` here again triggers a duplicate-mount-point error. + # Custom Gitea runner image: Ubuntu 24.04 + docker CLI + node + git + # + perl + ssh, runner user pre-joined to docker gid 998 so the + # auto-mounted /var/run/docker.sock is writable without --user root. container: - image: catthehacker/ubuntu:runner-latest - timeout-minutes: 15 + image: git.librete.ch/libretech/runner-image:v1 + timeout-minutes: 20 if: ${{ vars.DEPLOY_ENABLED == 'true' }} - outputs: - image_ref: ${{ steps.tags.outputs.image_ref }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - - name: Log in to registry - uses: docker/login-action@v3 + - uses: docker/login-action@v3 with: registry: ${{ secrets.REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASS }} - - name: Compute tags - id: tags - run: | - BASE="${{ secrets.REGISTRY }}/public/librenotes" - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - TAG="${GITHUB_REF##refs/tags/}" - echo "tags=${BASE}:${TAG},${BASE}:latest" >> "$GITHUB_OUTPUT" - echo "version=${TAG}" >> "$GITHUB_OUTPUT" - echo "image_ref=${BASE}:${TAG}" >> "$GITHUB_OUTPUT" - else - SHA7="${GITHUB_SHA::7}" - echo "tags=${BASE}:main,${BASE}:${SHA7}" >> "$GITHUB_OUTPUT" - echo "version=${SHA7}" >> "$GITHUB_OUTPUT" - echo "image_ref=${BASE}:main" >> "$GITHUB_OUTPUT" - fi + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.REGISTRY }}/public/librenotes + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} - uses: docker/build-push-action@v6 with: context: . push: true - tags: ${{ steps.tags.outputs.tags }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} build-args: | - VERSION=${{ steps.tags.outputs.version }} + VERSION=${{ steps.meta.outputs.version }} BUILDTIME=${{ github.event.head_commit.timestamp }} - deploy: - runs-on: ubuntu-latest - # Same image as build — bundles ssh, perl, curl. No docker needed. - container: - image: catthehacker/ubuntu:runner-latest - needs: build - timeout-minutes: 10 - if: ${{ vars.DEPLOY_ENABLED == 'true' }} - steps: - - name: Configure SSH + - name: Deploy to host env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + HEALTH_URL: ${{ secrets.HEALTH_URL }} run: | - mkdir -p ~/.ssh + mkdir -p ~/.ssh && chmod 700 ~/.ssh printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy chmod 600 ~/.ssh/id_deploy - ssh-keyscan -H "${DEPLOY_HOST#*@}" >> ~/.ssh/known_hosts 2>/dev/null || true - - name: Pull and restart on deploy host - env: - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} - IMAGE_REF: ${{ needs.build.outputs.image_ref }} - run: | - REMOTE_CMD='cd "$DEPLOY_PATH" || exit 1 - git pull --ff-only - # Always pin LIBRENOTES_IMAGE so first deploy works without manual - # .env priming and so rollback only ever needs an .env edit. - perl -i -pe "s|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=$IMAGE_REF|" .env - grep -q "^LIBRENOTES_IMAGE=" .env || echo "LIBRENOTES_IMAGE=$IMAGE_REF" >> .env - docker compose -f compose.yaml -f compose.netcup.yaml pull - docker compose -f compose.yaml -f compose.netcup.yaml up -d --remove-orphans' ssh -i ~/.ssh/id_deploy \ -o StrictHostKeyChecking=accept-new \ "$DEPLOY_HOST" \ - "DEPLOY_PATH='$DEPLOY_PATH' IMAGE_REF='$IMAGE_REF' bash -s" <<< "$REMOTE_CMD" + "set -e + cd '$DEPLOY_PATH' + git pull --ff-only + docker compose -f compose.yaml -f compose.netcup.yaml pull + docker compose -f compose.yaml -f compose.netcup.yaml up -d --remove-orphans" - - name: Verify health - env: - HEALTH_URL: ${{ secrets.HEALTH_URL }} - run: | - # Give the new container ~60s to come up, then poll for - # 200 from /healthz. Failure aborts the workflow. + # Wait up to 60s for /healthz to return 200. for i in $(seq 1 12); do - if curl -fsS "$HEALTH_URL" >/dev/null; then - echo "deploy verified" - exit 0 - fi + curl -fsS "$HEALTH_URL" >/dev/null && exit 0 sleep 5 done - echo "deploy verification failed" - exit 1 + echo "deploy health check failed"; exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 5e216d7..bc832b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Cloud-native multi-tenant notes application built on Notesium (MIT). ## Wave pipelines -- `gt-issue-*` pipelines use `tea` CLI with `--login librete` +- `gt-issue-*` pipelines use `tea` CLI with `--login libretech` - `gh-issue-*` pipelines use `gh` CLI - Personas must be registered in `wave.yaml` under `personas:` key — files alone aren't enough - `wave.yaml` is gitignored (local config) @@ -27,6 +27,6 @@ Cloud-native multi-tenant notes application built on Notesium (MIT). ## Gitea - Instance: https://git.librete.ch -- Auth: `tea` CLI, login name `librete` +- Auth: `tea` CLI, login name `libretech` (matches the Gitea username) - API: https://git.librete.ch/api/v1 - Issues: https://git.librete.ch/public/librenotes/issues diff --git a/compose.prod.yaml b/compose.prod.yaml deleted file mode 100644 index 1dfa74c..0000000 --- a/compose.prod.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# compose.prod.yaml — production overrides (generic, non-netcup). -# -# Use: -# docker compose -f compose.yaml -f compose.prod.yaml up -d -# -# For the netcup deployment, use `compose.netcup.yaml` instead. -# -# Inputs (env or .env file): -# LIBRENOTES_IMAGE registry image, e.g. git.librete.ch/public/librenotes:v0.1.0 -# LIBRENOTES_BASE_URL public origin, e.g. https://librenot.es -# LIBRENOTES_JWT_SECRET secrets manager value, NOT committed -# LIBRENOTES_SMTP_HOST real SMTP host -# LIBRENOTES_SMTP_PORT submission port (587 default) -# LIBRENOTES_SMTP_USER SMTP credential -# LIBRENOTES_SMTP_PASS SMTP credential -# LIBRENOTES_SMTP_FROM envelope sender (e.g. no-reply@librenot.es) -services: - librenotes: - image: ${LIBRENOTES_IMAGE} - build: !reset null - environment: - LIBRENOTES_BASE_URL: ${LIBRENOTES_BASE_URL} - LIBRENOTES_JWT_SECRET: ${LIBRENOTES_JWT_SECRET} - LIBRENOTES_SMTP_HOST: ${LIBRENOTES_SMTP_HOST} - LIBRENOTES_SMTP_PORT: ${LIBRENOTES_SMTP_PORT:-587} - LIBRENOTES_SMTP_USER: ${LIBRENOTES_SMTP_USER} - LIBRENOTES_SMTP_PASS: ${LIBRENOTES_SMTP_PASS} - LIBRENOTES_SMTP_FROM: ${LIBRENOTES_SMTP_FROM} - healthcheck: - test: ["CMD", "/librenotes", "healthcheck"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - deploy: - resources: - limits: - memory: 256M - reservations: - memory: 64M - logging: - driver: json-file - options: - max-size: "10m" - max-file: "5" diff --git a/docs/operations.md b/docs/operations.md index b73b85a..b41e272 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -51,14 +51,16 @@ docker compose -f compose.yaml -f compose.netcup.yaml up -d ### Rollback -Image tags are immutable per commit / version. To roll back, set -`LIBRENOTES_IMAGE` to the previous tag in `.env` and run the same -`up -d` command. The deployment workflow does not auto-rollback -on health-check failure — failed health alerts the operator via -the workflow itself, who can then redeploy the prior tag manually. +Tag pushes publish immutable tags (`:vX.Y.Z`). To pin or roll back, +edit `LIBRENOTES_IMAGE` in `/srv/librenotes/.env` and re-run pull + +up. The deployment workflow itself never rewrites the file — failed +health checks abort the workflow and the operator redeploys +manually. ```sh # Example: roll back to v0.1.0 +ssh netcup +cd /srv/librenotes perl -i -pe 's|^LIBRENOTES_IMAGE=.*|LIBRENOTES_IMAGE=git.librete.ch/public/librenotes:v0.1.0|' .env docker compose -f compose.yaml -f compose.netcup.yaml pull docker compose -f compose.yaml -f compose.netcup.yaml up -d @@ -67,17 +69,17 @@ docker compose -f compose.yaml -f compose.netcup.yaml up -d ### Gitea Actions runner Workflows run on the netcup `act_runner` (see `runner/` stack in -the netcup umbrella). Both jobs declare `container: catthehacker/ubuntu:runner-latest` -because: +the netcup umbrella). Both `ci.yml` and `deploy.yml` declare +`container: image: git.librete.ch/libretech/runner-image:v1` — +a bespoke Ubuntu 24.04 image (built and signed by us, hosted on +the same Gitea instance) that bundles `git`, `make`, `node`, +`perl`, `ssh`, and a docker CLI. The image's `runner` user is +pre-joined to `docker` group gid 998 so the auto-mounted +`/var/run/docker.sock` is writable without `--user root`. -- The default runner label image (`node:20-bookworm`) lacks `make` - and `docker`, both required by the workflows. -- The `runner-latest` image bundles `make`, `git`, `curl`, `ssh`, - `node`, plus a docker CLI. - -The runner config (`runner/config.yaml`) declares -`/var/run/docker.sock` as a `valid_volume` so the build job can -mount the host socket and push images via `docker/build-push-action`. +The runner config (`runner/config.yaml`) whitelists +`/var/run/docker.sock` under `valid_volumes` to allow the +auto-mount. ## Backups -- 2.36.6