From 80868e0f7ab2bacd343b8971c1a50c998a715a4f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 21 Jun 2026 20:51:13 +0300 Subject: [PATCH] ci: align Gitea CI/CD + Docker with the notify-bridge template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt the proven notify-bridge pipeline pattern and fix deployment bugs. Workflows: - build.yml: split into parallel frontend / backend / build-image jobs. Run svelte-check + vitest + `go vet ./...` + `go test ./internal/...` (tests were never executed in CI). Use buildx with GHA layer cache and pin Go to 1.25. Quote the `if:` skip-guard so it is valid YAML. - release.yml: gate the release on a passing test job, then build & push the image, then create the Gitea release LAST so a failed image build can no longer leave an orphan release. Use buildx + registry buildcache, a hard registry login (a push failure now fails the release), and auto-generate a changelog between tags. Docker: - Dockerfile: pin golang to 1.25 (matches go.mod's `go 1.25.0`), add BuildKit cache mounts for the module + build caches, an OCI source label, VOLUME /app/data, and a HEALTHCHECK on /readyz. - docker-compose.yml: fix the healthcheck — it targeted POST-only /api/auth/login (405 -> always unhealthy); now /readyz. Point the image name at the Gitea registry tag with build-from-source as the default. - .dockerignore: exclude ~95 MB of stray binaries, logs, env, and CI/doc files from the build context. --- .dockerignore | 52 +++++++-- .gitea/workflows/build.yml | 72 +++++++++---- .gitea/workflows/release.yml | 203 +++++++++++++++++++++++------------ Dockerfile | 23 +++- docker-compose.yml | 13 ++- 5 files changed, 263 insertions(+), 100 deletions(-) diff --git a/.dockerignore b/.dockerignore index 057d540..dfa8fb4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,47 @@ +# VCS / tooling .git -node_modules -web/node_modules -web/build -data -*.md -plans/ -.claude/ +.gitignore .dockerignore +.gitea/ +.github/ +.claude/ +.code-review-graph/ +.vex.toml +.facts-sync.json +.facts-suggestions.md + +# Node / frontend build artifacts (frontend stage rebuilds web/build) +node_modules/ +web/node_modules/ +web/build/ +web/.svelte-kit/ + +# Runtime / local data +data/ +.env +.env.* +*.log + +# Compiled binaries (rebuilt inside the image) +tinyforge +tinyforge.exe +tinyforge-server.exe +server.exe +docker-watcher +docker-watcher.exe +docker-watcher.exe~ +/cli +/cli.exe + +# Build/orchestration files not needed inside the image +Dockerfile +docker-compose.yml +Makefile +*.example.yaml + +# Docs / planning / design (not needed at runtime) +*.md +docs/ +plans/ +design-mockups/ +test-data/ diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ab07943..a8dc6a3 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -5,34 +5,70 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: - build: + frontend: + # Skip the build on release-bump commits — the tag push runs release.yml. + if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + working-directory: web + run: npm ci --no-audit + + - name: Svelte check + working-directory: web + run: npm run check + + - name: Unit tests (vitest) + working-directory: web + run: npm run test + + - name: Build frontend + working-directory: web + run: npm run build + + backend: + if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.24' - - - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install frontend dependencies - working-directory: web - run: npm ci --no-audit - - - name: Build frontend - working-directory: web - run: npm run build + go-version: '1.25' + cache-dependency-path: go.sum - name: Vet Go code run: go vet ./... - - name: Build Go binary - run: CGO_ENABLED=0 go build -ldflags="-s -w" -o tinyforge ./cmd/server + - name: Run Go tests + run: go test ./internal/... -count=1 - - name: Build Docker image - run: docker build -t tinyforge:dev . + build-image: + if: "${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}" + needs: [frontend, backend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (no push) + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: tinyforge:ci-${{ gitea.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 18e4b3b..7232a01 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -10,19 +10,109 @@ env: REGISTRY: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge jobs: - create-release: + # ─────────────────────────────────────────────────────────────────────── + # Gate the release on a passing test suite. A tagged release must never + # ship code that fails `go vet` / `go test`. + # ─────────────────────────────────────────────────────────────────────── + test: runs-on: ubuntu-latest - outputs: - release_id: ${{ steps.create.outputs.release_id }} steps: - - name: Fetch RELEASE_NOTES.md only + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Vet Go code + run: go vet ./... + + - name: Run Go tests + run: go test ./internal/... -count=1 + + # ─────────────────────────────────────────────────────────────────────── + # Build + push the image FIRST. If this fails, no release is created + # (create-release depends on it) — so we never leave an orphan release + # pointing at a tag with no published image. + # ─────────────────────────────────────────────────────────────────────── + build-docker: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Compute tags + id: meta + run: | + TAG="${{ gitea.ref_name }}" + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Detect pre-release (alpha/beta/rc) — these do NOT get :latest. + if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then + echo "is_pre=true" >> "$GITHUB_OUTPUT" + else + echo "is_pre=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.SERVER_HOST }} + username: ${{ gitea.actor }} + password: ${{ secrets.DEPLOY_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}:${{ steps.meta.outputs.tag }} + ${{ env.REGISTRY }}:${{ steps.meta.outputs.version }} + ${{ env.REGISTRY }}:sha-${{ gitea.sha }} + ${{ steps.meta.outputs.is_pre == 'false' && format('{0}:latest', env.REGISTRY) || '' }} + cache-from: type=registry,ref=${{ env.REGISTRY }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}:buildcache,mode=max + + - name: Trigger redeploy webhook + if: steps.meta.outputs.is_pre == 'false' + continue-on-error: true + run: | + if [ -n "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" ]; then + echo "Triggering redeploy webhook..." + curl -sf -X POST "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" \ + --max-time 30 || echo "::warning::Redeploy webhook failed" + else + echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy" + fi + + # ─────────────────────────────────────────────────────────────────────── + # Create the Gitea release LAST — body = RELEASE_NOTES.md + auto-changelog. + # ─────────────────────────────────────────────────────────────────────── + create-release: + needs: build-docker + runs-on: ubuntu-latest + steps: + - name: Checkout (full history for changelog) uses: actions/checkout@v4 with: - sparse-checkout: RELEASE_NOTES.md - sparse-checkout-cone-mode: false + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ gitea.ref_name }}" ]; then + git log --oneline --no-decorate -n 20 > /tmp/changelog.txt + else + git log --oneline --no-decorate "${PREV_TAG}..HEAD" > /tmp/changelog.txt + fi - name: Create Gitea release - id: create env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} run: | @@ -42,74 +132,49 @@ jobs: echo "Found RELEASE_NOTES.md" else export RELEASE_NOTES="" - echo "No RELEASE_NOTES.md found — release will have no body" + echo "No RELEASE_NOTES.md found — release body = changelog only" fi - BODY_JSON=$(python3 -c " + # Build release body (notes + changelog) via Python to avoid shell + # escaping and CLI length limits. + export TAG VERSION IS_PRE + python3 <<'PY' import json, os - notes = os.environ.get('RELEASE_NOTES', '') - print(json.dumps(notes.strip())) - ") - # Create release via Gitea API - RELEASE=$(curl -s -X POST "$BASE_URL/releases" \ + notes = os.environ.get('RELEASE_NOTES', '') + changelog = open('/tmp/changelog.txt').read().strip() + + sections = [] + if notes.strip(): + sections.append(notes.strip()) + if changelog: + sections.append('## Changelog\n\n' + changelog) + + payload = { + 'tag_name': os.environ['TAG'], + 'name': os.environ['VERSION'], + 'body': '\n\n'.join(sections), + 'draft': False, + 'prerelease': os.environ['IS_PRE'] == 'true', + } + with open('/tmp/release-payload.json', 'w') as f: + json.dump(payload, f) + PY + + HTTP=$(curl -s -o /tmp/release-resp.json -w "%{http_code}" \ + -X POST "$BASE_URL/releases" \ -H "Authorization: token $DEPLOY_TOKEN" \ -H "Content-Type: application/json" \ - -d "{ - \"tag_name\": \"$TAG\", - \"name\": \"$VERSION\", - \"body\": $BODY_JSON, - \"draft\": false, - \"prerelease\": $IS_PRE - }") + --data-binary @/tmp/release-payload.json) - # Fallback: if release already exists for this tag, reuse it - RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null) - if [ -z "$RELEASE_ID" ]; then - echo "::warning::Release already exists for tag $TAG — reusing existing release" - RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \ - -H "Authorization: token $DEPLOY_TOKEN") - RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") - fi - echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" - echo "Created release $RELEASE_ID for $TAG" - - build-docker: - needs: create-release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Login to Gitea Container Registry - id: docker-login - continue-on-error: true - run: | - echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \ - "$SERVER_HOST" -u "${{ gitea.actor }}" --password-stdin - - - name: Build and tag - if: steps.docker-login.outcome == 'success' - run: | - TAG="${{ gitea.ref_name }}" - VERSION="${TAG#v}" - docker build -t "$REGISTRY:$TAG" -t "$REGISTRY:$VERSION" . - # Tag as 'latest' only for stable releases - if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then - docker tag "$REGISTRY:$TAG" "$REGISTRY:latest" - fi - - - name: Push - if: steps.docker-login.outcome == 'success' - run: docker push "$REGISTRY" --all-tags - - - name: Trigger Portainer redeploy - if: steps.docker-login.outcome == 'success' - continue-on-error: true - run: | - if [ -n "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" ]; then - echo "Triggering Portainer redeploy..." - curl -sf -X POST "${{ secrets.DOCKER_REDEPLOY_WEBHOOK_URL }}" \ - --max-time 30 || echo "::warning::Portainer webhook failed" + echo "POST /releases → HTTP $HTTP" + if [ "$HTTP" = "201" ]; then + RELEASE_ID=$(python3 -c "import json; print(json.load(open('/tmp/release-resp.json'))['id'])") + echo "Created release $RELEASE_ID for $TAG" + elif [ "$HTTP" = "409" ] || grep -q "already exists" /tmp/release-resp.json; then + echo "::warning::Release already exists for tag $TAG — reusing" else - echo "DOCKER_REDEPLOY_WEBHOOK_URL not set — skipping auto-deploy" + echo "::error::Failed to create release for $TAG (HTTP $HTTP)" + head -c 2000 /tmp/release-resp.json; echo + exit 1 fi diff --git a/Dockerfile b/Dockerfile index 54dbcc0..9831eb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.7 # Stage 1: Build frontend FROM node:20-alpine AS frontend-builder @@ -9,25 +10,33 @@ COPY web/ ./ RUN npm run build # Stage 2: Build Go binary -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.25-alpine AS backend-builder RUN apk add --no-cache git ca-certificates WORKDIR /build COPY go.mod go.sum ./ ENV GOTOOLCHAIN=auto -RUN go mod download +# Cache mounts persist the module + build caches across rebuilds (BuildKit). +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . # Copy built frontend into the expected embed location. COPY --from=frontend-builder /build/web/build ./web/build -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tinyforge ./cmd/server +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /tinyforge ./cmd/server # Stage 3: Minimal runtime image FROM alpine:3.19 -RUN apk add --no-cache ca-certificates tzdata +LABEL org.opencontainers.image.source="https://git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge" +LABEL org.opencontainers.image.title="Tinyforge" +LABEL org.opencontainers.image.description="Self-hosted Docker deployment + mini-CI platform" + +RUN apk add --no-cache ca-certificates tzdata wget # Create non-root user. RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app @@ -46,4 +55,10 @@ EXPOSE 8080 ENV DATA_DIR=/app/data ENV LISTEN_ADDR=:8080 +VOLUME /app/data + +# /readyz is the public readiness probe (pings the DB); /livez is liveness. +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/readyz || exit 1 + ENTRYPOINT ["/app/tinyforge"] diff --git a/docker-compose.yml b/docker-compose.yml index 32f5608..9d7d3ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,13 @@ services: tinyforge: + # Default: build from source so a fresh clone works out of the box. build: . - image: tinyforge:latest + # Image name doubles as the Gitea registry tag. To DEPLOY the pre-built + # image instead of building (e.g. Portainer pulling on a webhook), comment + # out `build:` above — compose will then pull this tag. `:latest` is pushed + # only for stable (non pre-release) releases, and the registry may require + # `docker login git.dolgolyov-family.by` first if the package is private. + image: git.dolgolyov-family.by/alexei.dolgolyov/tiny-forge:latest container_name: tinyforge restart: unless-stopped ports: @@ -31,7 +37,10 @@ services: networks: - staging-net healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/auth/login"] + # /readyz is the public readiness probe (pings the DB, rate-limited). + # The previous target (/api/auth/login) is POST-only, so a GET/spider + # request returned 405 and the container was always reported unhealthy. + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/readyz"] interval: 30s timeout: 5s retries: 3