name: Release on: push: tags: - "v*" # e.g. v1.0.0, v1.2.3 concurrency: group: ${{ gitea.workflow }}-${{ gitea.ref }} cancel-in-progress: true jobs: # ── backend: vet & test ─────────────────────────────────────────────────────── test-backend: name: Test backend runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - name: go vet working-directory: backend run: go vet ./... - name: Run tests working-directory: backend run: go test -short -race -count=1 -timeout=60s ./... # ── ui: type-check & build ──────────────────────────────────────────────────── check-ui: name: Check ui runs-on: ubuntu-latest defaults: run: working-directory: ui steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" cache: npm cache-dependency-path: ui/package-lock.json - name: Install dependencies run: npm ci - name: Type check run: npm run check - name: Build run: npm run build - name: Upload build artifacts uses: actions/upload-artifact@v3 with: name: ui-build path: ui/build retention-days: 1 # ── ui: source map upload ───────────────────────────────────────────────────── # Commented out — re-enable when GlitchTip source map uploads are needed again. # # upload-sourcemaps: # name: Upload source maps # runs-on: ubuntu-latest # needs: [check-ui] # steps: # - name: Compute release version (strip leading v) # id: ver # run: | # V="${{ gitea.ref_name }}" # echo "version=${V#v}" >> "$GITHUB_OUTPUT" # # - name: Download build artifacts # uses: actions/download-artifact@v3 # with: # name: ui-build # path: build # # - name: Install sentry-cli # run: npm install -g @sentry/cli # # - name: Inject debug IDs into build artifacts # run: sentry-cli sourcemaps inject ./build # env: # SENTRY_URL: https://errors.libnovel.cc/ # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel # SENTRY_PROJECT: ui # # - name: Upload injected build (for docker-ui) # uses: actions/upload-artifact@v3 # with: # name: ui-build-injected # path: build # retention-days: 1 # # - name: Create GlitchTip release # run: sentry-cli releases new ${{ steps.ver.outputs.version }} # env: # SENTRY_URL: https://errors.libnovel.cc/ # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel # SENTRY_PROJECT: ui # # - name: Upload source maps to GlitchTip # run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }} # env: # SENTRY_URL: https://errors.libnovel.cc/ # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel # SENTRY_PROJECT: ui # # - name: Finalize GlitchTip release # run: sentry-cli releases finalize ${{ steps.ver.outputs.version }} # env: # SENTRY_URL: https://errors.libnovel.cc/ # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel # SENTRY_PROJECT: ui # # - name: Prune old GlitchTip releases (keep latest 10) # run: | # set -euo pipefail # KEEP=10 # OLD=$(curl -sf \ # -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \ # "$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \ # | python3 -c " # import sys, json # releases = json.load(sys.stdin) # for r in releases[$KEEP:]: # print(r['version']) # " KEEP=$KEEP) # for ver in $OLD; do # echo "Deleting old release: $ver" # sentry-cli releases delete "$ver" || true # done # env: # SENTRY_URL: https://errors.libnovel.cc # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel # SENTRY_PROJECT: ui # ── docker: all images in one job (single login) ────────────────────────────── # backend, runner, ui, and caddy are built sequentially in one job so Docker # Hub only needs to be authenticated once. This also eliminates 3 redundant # checkout + setup-buildx + scheduler round-trips compared to separate jobs. docker: name: Docker runs-on: ubuntu-latest needs: [test-backend, check-ui] steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 # Single login — credential is written to ~/.docker/config.json and # reused by all subsequent build-push-action steps in this job. - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} # ── backend ────────────────────────────────────────────────────────────── - name: Docker meta / backend id: meta-backend uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USER }}/libnovel-backend tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest - name: Build and push / backend uses: docker/build-push-action@v6 with: context: backend target: backend push: true tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} build-args: | VERSION=${{ steps.meta-backend.outputs.version }} COMMIT=${{ gitea.sha }} cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest cache-to: type=inline # ── runner ─────────────────────────────────────────────────────────────── - name: Docker meta / runner id: meta-runner uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USER }}/libnovel-runner tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest - name: Build and push / runner uses: docker/build-push-action@v6 with: context: backend target: runner push: true tags: ${{ steps.meta-runner.outputs.tags }} labels: ${{ steps.meta-runner.outputs.labels }} build-args: | VERSION=${{ steps.meta-runner.outputs.version }} COMMIT=${{ gitea.sha }} cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest cache-to: type=inline # ── ui ─────────────────────────────────────────────────────────────────── - name: Download ui build artifacts uses: actions/download-artifact@v3 with: name: ui-build path: ui/build - name: Allow build/ into Docker context (override .dockerignore) run: | grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp mv ui/.dockerignore.tmp ui/.dockerignore - name: Docker meta / ui id: meta-ui uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USER }}/libnovel-ui tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest - name: Build and push / ui uses: docker/build-push-action@v6 with: context: ui push: true tags: ${{ steps.meta-ui.outputs.tags }} labels: ${{ steps.meta-ui.outputs.labels }} build-args: | BUILD_VERSION=${{ steps.meta-ui.outputs.version }} BUILD_COMMIT=${{ gitea.sha }} BUILD_TIME=${{ gitea.event.head_commit.timestamp }} PREBUILT=1 cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest cache-to: type=inline # ── caddy ──────────────────────────────────────────────────────────────── - name: Docker meta / caddy id: meta-caddy uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USER }}/libnovel-caddy tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest - name: Build and push / caddy uses: docker/build-push-action@v6 with: context: caddy push: true tags: ${{ steps.meta-caddy.outputs.tags }} labels: ${{ steps.meta-caddy.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest cache-to: type=inline # ── pocketbase ─────────────────────────────────────────────────────────── - name: Docker meta / pocketbase id: meta-pocketbase uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKER_USER }}/libnovel-pocketbase tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest - name: Build and push / pocketbase uses: docker/build-push-action@v6 with: context: backend target: pocketbase push: true tags: ${{ steps.meta-pocketbase.outputs.tags }} labels: ${{ steps.meta-pocketbase.outputs.labels }} cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-pocketbase:latest cache-to: type=inline # ── deploy: sync docker-compose.yml + restart prod ─────────────────────────── # Runs after all images are pushed to Docker Hub. # Copies the compose file from the tagged commit to the server, pulls the new # images, and restarts only the services whose image or config changed. # --remove-orphans cleans up containers no longer defined in the compose file # (e.g. the now-removed pb-init container). # # Required Gitea secrets: # PROD_HOST — prod server IP or hostname # PROD_USER — SSH login user (typically root) # PROD_SSH_KEY — private key whose public half is in authorized_keys # PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H deploy: name: Deploy to prod runs-on: ubuntu-latest needs: [docker] steps: - uses: actions/checkout@v4 - name: Install SSH key run: | mkdir -p ~/.ssh printf '%s\n' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key printf '%s\n' "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts - name: Copy docker-compose.yml to prod run: | scp -i ~/.ssh/deploy_key \ docker-compose.yml \ "${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:/opt/libnovel/docker-compose.yml" - name: Pull new images and restart changed services run: | ssh -i ~/.ssh/deploy_key \ "${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}" \ 'set -euo pipefail cd /opt/libnovel doppler run -- docker compose pull doppler run -- docker compose up -d --remove-orphans' # ── Gitea release ───────────────────────────────────────────────────────────── release: name: Gitea Release runs-on: ubuntu-latest needs: [docker] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Extract release notes from tag commit id: notes run: | set -euo pipefail # Subject line (first line of commit message) → release title SUBJECT=$(git log -1 --format="%s" "${{ gitea.sha }}") # Body (everything after the blank line) → release body BODY=$(git log -1 --format="%b" "${{ gitea.sha }}" | sed '/^Co-Authored-By:/d' | sed '/^[[:space:]]*$/{ N; /^\n$/d }' | sed 's/^[[:space:]]*$//' | awk 'NF || !p; {p = !NF}') echo "title=${SUBJECT}" >> "$GITHUB_OUTPUT" # Use a heredoc delimiter to safely handle multi-line body { echo "body<> "$GITHUB_OUTPUT" - name: Create release uses: https://gitea.com/actions/gitea-release-action@v1 with: token: ${{ secrets.GITEA_TOKEN }} title: ${{ steps.notes.outputs.title }} body: ${{ steps.notes.outputs.body }}