diff --git a/.gitea/scripts/build-plan.sh b/.gitea/scripts/build-plan.sh new file mode 100755 index 0000000..9e87613 --- /dev/null +++ b/.gitea/scripts/build-plan.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# build-plan.sh +# Calculates whether to build from scratch, skip tests, or retag an existing image. +# +# Inputs (via env or arguments): +# REF: Git reference (e.g. refs/heads/dev, refs/heads/main, refs/tags/v0.10.0) +# COMMIT: Commit SHA or HEAD (defaults to HEAD) +# IMAGE: Docker image repository name (e.g. git.dsit.me/sandrodach/mindsift) +# PKG_VERSION: Package version from package.json (optional, auto-detected) +# CHECK_IMAGE_CMD: Command to check image existence in registry (default: "docker manifest inspect") + +REF="${REF:-${1:-}}" +COMMIT="${COMMIT:-${2:-HEAD}}" +IMAGE="${IMAGE:-${3:-}}" +PKG_VERSION="${PKG_VERSION:-${4:-}}" +CHECK_CMD="${CHECK_IMAGE_CMD:-docker manifest inspect}" + +if [ -z "${REF}" ]; then + REF="$(git symbolic-ref -q HEAD 2>/dev/null || git describe --tags --exact-match 2>/dev/null || echo "refs/heads/dev")" +fi + +if [ -z "${PKG_VERSION}" ] && [ -f "frontend/package.json" ]; then + PKG_VERSION="$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" +fi + +SHORT_SHA="$(git rev-parse --short=8 "${COMMIT}^{commit}" 2>/dev/null || echo "unknown")" +SHORT_TREE="$(git rev-parse --short=12 "${COMMIT}^{tree}" 2>/dev/null || echo "unknown")" + +image_exists() { + local target="$1" + ${CHECK_CMD} "${target}" >/dev/null 2>&1 +} + +ACTION="build" +SKIP_TESTS="0" +APP_VERSION="" +TAGS="" +SOURCE_IMAGE="" +REASON="" + +case "${REF}" in + refs/heads/dev|dev) + ACTION="build" + SKIP_TESTS="0" + APP_VERSION="" + TAGS="dev tree-${SHORT_TREE}" + REASON="Dev branch push: running full frontend and backend tests, tagging :dev and :tree-${SHORT_TREE}" + ;; + + refs/heads/main|main) + ACTION="build" + APP_VERSION="v${PKG_VERSION}" + TAGS="latest sha-${SHORT_SHA} tree-${SHORT_TREE}" + + # Check if this exact tree was already successfully tested on dev + if [ -n "${IMAGE}" ] && image_exists "${IMAGE}:tree-${SHORT_TREE}"; then + SKIP_TESTS="1" + REASON="Main branch merge/push: tree :tree-${SHORT_TREE} was already tested on dev. Skipping tests (SKIP_TESTS=1)." + else + SKIP_TESTS="0" + REASON="Main branch merge/push: tree :tree-${SHORT_TREE} not found in registry. Running full tests (SKIP_TESTS=0)." + fi + ;; + + refs/tags/v*|v*) + TAG_NAME="${REF#refs/tags/}" + # If pre-release (contains hyphen like v1.0.0-rc.1), do not tag :stable + if [[ "${TAG_NAME}" == *-* ]]; then + TARGET_TAGS="${TAG_NAME}" + else + TARGET_TAGS="${TAG_NAME} stable" + fi + + # Check if a main image was already built for this exact commit + if [ -n "${IMAGE}" ] && image_exists "${IMAGE}:sha-${SHORT_SHA}"; then + ACTION="retag" + SOURCE_IMAGE="${IMAGE}:sha-${SHORT_SHA}" + SKIP_TESTS="1" + TAGS="${TARGET_TAGS}" + REASON="Release tag ${TAG_NAME} points to commit with existing image (${IMAGE}:sha-${SHORT_SHA}). Retagging without rebuild." + else + ACTION="build" + APP_VERSION="${TAG_NAME}" + TAGS="${TARGET_TAGS}" + if [ -n "${IMAGE}" ] && image_exists "${IMAGE}:tree-${SHORT_TREE}"; then + SKIP_TESTS="1" + REASON="Release tag ${TAG_NAME}: :sha-${SHORT_SHA} not found, but tree was tested on dev. Building with SKIP_TESTS=1." + else + SKIP_TESTS="0" + REASON="Release tag ${TAG_NAME}: :sha-${SHORT_SHA} not found and tree not verified. Building with full tests (SKIP_TESTS=0)." + fi + fi + ;; + + *) + REF_SLUG="$(echo "${REF#refs/heads/}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')" + ACTION="build" + SKIP_TESTS="0" + APP_VERSION="" + TAGS="${REF_SLUG}" + REASON="Feature/other branch (${REF}): building with full tests" + ;; +esac + +# Output to stdout +echo "ACTION=${ACTION}" +echo "SKIP_TESTS=${SKIP_TESTS}" +echo "APP_VERSION=${APP_VERSION}" +echo "TAGS=${TAGS}" +echo "SOURCE_IMAGE=${SOURCE_IMAGE}" +echo "SHORT_SHA=${SHORT_SHA}" +echo "SHORT_TREE=${SHORT_TREE}" +echo "REASON=${REASON}" + +# Output to GITHUB_OUTPUT if present +if [ -n "${GITHUB_OUTPUT:-}" ] && [ -w "${GITHUB_OUTPUT}" ]; then + echo "action=${ACTION}" >> "${GITHUB_OUTPUT}" + echo "skip_tests=${SKIP_TESTS}" >> "${GITHUB_OUTPUT}" + echo "app_version=${APP_VERSION}" >> "${GITHUB_OUTPUT}" + echo "tags=${TAGS}" >> "${GITHUB_OUTPUT}" + echo "source_image=${SOURCE_IMAGE}" >> "${GITHUB_OUTPUT}" + echo "short_sha=${SHORT_SHA}" >> "${GITHUB_OUTPUT}" + echo "short_tree=${SHORT_TREE}" >> "${GITHUB_OUTPUT}" + echo "reason=${REASON}" >> "${GITHUB_OUTPUT}" +fi diff --git a/.gitea/scripts/test-build-plan.sh b/.gitea/scripts/test-build-plan.sh new file mode 100755 index 0000000..c0a387d --- /dev/null +++ b/.gitea/scripts/test-build-plan.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLAN_SCRIPT="${SCRIPT_DIR}/build-plan.sh" +IMAGE="git.dsit.me/sandrodach/mindsift" + +COMMIT_SHA="$(git rev-parse --short=8 HEAD)" +COMMIT_TREE="$(git rev-parse --short=12 HEAD^{tree})" + +# Helper mock check script +MOCK_BIN="${SCRIPT_DIR}/.mock_check.sh" +cat << 'EOF' > "${MOCK_BIN}" +#!/usr/bin/env bash +TARGET="$1" +if [ "${MOCK_MODE}" = "tree_only" ] && [[ "${TARGET}" == *":tree-"* ]]; then + exit 0 +fi +if [ "${MOCK_MODE}" = "sha_only" ] && [[ "${TARGET}" == *":sha-"* ]]; then + exit 0 +fi +if [ "${MOCK_MODE}" = "all" ]; then + exit 0 +fi +exit 1 +EOF +chmod +x "${MOCK_BIN}" +trap 'rm -f "${MOCK_BIN}"' EXIT + +echo "============================================================" +echo "TEST 1: Push to dev" +echo "============================================================" +export MOCK_MODE="none" +REF="refs/heads/dev" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 2: Push/Merge to main (Tree ALREADY tested on dev)" +echo "============================================================" +export MOCK_MODE="tree_only" +REF="refs/heads/main" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 3: Push to main (Tree NOT yet tested)" +echo "============================================================" +export MOCK_MODE="none" +REF="refs/heads/main" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 4: Release Tag v0.10.0 (Main sha image ALREADY exists)" +echo "============================================================" +export MOCK_MODE="sha_only" +REF="refs/tags/v0.10.0" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 5: Release Tag v0.10.0 (Sha NOT found, but Tree exists)" +echo "============================================================" +export MOCK_MODE="tree_only" +REF="refs/tags/v0.10.0" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 6: Release Tag v0.10.0 (Sha NOT found, Tree NOT found)" +echo "============================================================" +export MOCK_MODE="none" +REF="refs/tags/v0.10.0" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 7: Pre-release Tag v0.10.0-rc.1 (Sha image exists)" +echo "============================================================" +export MOCK_MODE="sha_only" +REF="refs/tags/v0.10.0-rc.1" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index 0943af5..da67ce6 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -1,10 +1,5 @@ name: MindSift CI & Docker Release -# Image tags: -# push to dev -> :dev -# push/merge main -> :latest -# git tag v1.2.0 -> :v1.2.0 and :stable -# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays on: push: branches: @@ -21,59 +16,54 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - - name: Determine Registry & Docker Tag - id: prep - run: | - SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}" - REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##') - if [ -z "${REGISTRY_HOST}" ]; then - REGISTRY_HOST="git.dsit.me" - fi - - REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}" - IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]') - REF_NAME="${GITHUB_REF_NAME:-latest}" - ACTOR_NAME="${GITHUB_ACTOR:-sandrodach}" - - # Ersetze evtl. Slashes in Branch-Namen (z.B. rc/1.1 -> rc-1.1) für valide Docker-Tags - DOCKER_TAG=$(echo "${REF_NAME}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]') - - # All tags of this run (space separated) - case "${GITHUB_REF:-}" in - refs/tags/*-*) TAGS="${DOCKER_TAG}" ;; - refs/tags/*) TAGS="${DOCKER_TAG} stable" ;; - refs/heads/main) TAGS="latest" ;; - *) TAGS="${DOCKER_TAG}" ;; - esac - - echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" - echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" - echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT" - echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" - echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" - # Version in the app header: tags show the tag, main the package version, everything else - # (dev, feature branches) keeps the "-dev" suffix from vite.config.ts - APP_VERSION="" - case "${GITHUB_REF:-}" in - refs/tags/*) APP_VERSION="${REF_NAME}" ;; - refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;; - esac - echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT" - echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" - - name: Login to Gitea Container Registry uses: docker/login-action@v3 with: - registry: ${{ steps.prep.outputs.registry }} - username: ${{ steps.prep.outputs.actor }} + registry: ${{ vars.REGISTRY_HOST || 'git.dsit.me' }} + username: ${{ github.actor }} password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }} - - name: Build & Run All Checks in Container, then Push + - name: Calculate Build Plan + id: plan run: | - IMAGE="${{ steps.prep.outputs.image }}" - TAG_ARGS="" - for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done - echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})" - DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} . - echo "✅ All checks & tests passed inside container! Pushing..." - for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done + SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}" + REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##') + [ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me" + REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}" + IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]') + + echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" + + REF="${GITHUB_REF:-}" COMMIT="${GITHUB_SHA:-HEAD}" IMAGE="${IMAGE_NAME}" .gitea/scripts/build-plan.sh + + - name: Execute Build or Retag & Push + run: | + IMAGE="${{ steps.plan.outputs.image }}" + ACTION="${{ steps.plan.outputs.action }}" + TAGS="${{ steps.plan.outputs.tags }}" + + if [ "${ACTION}" = "retag" ]; then + SOURCE="${{ steps.plan.outputs.source_image }}" + echo "🏷️ Re-tagging existing image ${SOURCE} without rebuilding (Reason: ${{ steps.plan.outputs.reason }})" + docker pull "${SOURCE}" + for t in ${TAGS}; do + docker tag "${SOURCE}" "${IMAGE}:${t}" + docker push "${IMAGE}:${t}" + done + else + SKIP_TESTS="${{ steps.plan.outputs.skip_tests }}" + APP_VERSION="${{ steps.plan.outputs.app_version }}" + TAG_ARGS="" + for t in ${TAGS}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done + + echo "🚀 Building container (SKIP_TESTS=${SKIP_TESTS}, APP_VERSION=${APP_VERSION}) for tags: ${TAGS}" + echo "ℹ️ Reason: ${{ steps.plan.outputs.reason }}" + DOCKER_BUILDKIT=1 docker build --progress=plain \ + --build-arg APP_VERSION="${APP_VERSION}" \ + --build-arg SKIP_TESTS="${SKIP_TESTS}" \ + ${TAG_ARGS} . + + echo "✅ Build complete! Pushing..." + for t in ${TAGS}; do docker push "${IMAGE}:${t}"; done + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c006ca..1cd9887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts). - PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server). +### Changed +- CI/CD & Pipeline: Schlankere Builds ohne Dreifach-Kompilierung. Bei Release-Tags wird das bestehende `main`-Image (`:sha-`) per Umtaggen direkt als `:vX.Y.Z` und `:stable` weiterverwendet. +- CI/CD: Bedingte Testausführung über `ARG SKIP_TESTS=0` im Dockerfile; überspringt Frontend- und Backend-Tests auf `main`, wenn der Git-Tree (`:tree-`) bereits auf `dev` getestet wurde. +- Build-Caching: BuildKit-Caches für Go-Module (`/go/pkg/mod`, `/root/.cache/go-build`) und npm (`/root/.npm`) im Dockerfile aktiviert. + ## [0.10.0] - 2026-10-30 ### Added diff --git a/Dockerfile b/Dockerfile index 04f7a85..530d168 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,17 @@ FROM node:22-alpine AS frontend-builder WORKDIR /app/frontend COPY frontend/package*.json ./ -RUN npm install --no-audit --no-fund +RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund COPY frontend/ ./ # Version shown in the header: the git tag for releases (set by CI), otherwise "-dev" ARG APP_VERSION="" -RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build +ARG SKIP_TESTS=0 +RUN if [ "$SKIP_TESTS" != "1" ]; then \ + npx tsc --noEmit && npm run lint && npm run test:run; \ + else \ + echo "Skipping frontend tests (SKIP_TESTS=1)"; \ + fi && \ + APP_VERSION="${APP_VERSION}" npm run build # ============================================================================== # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) @@ -19,7 +25,8 @@ RUN apk add --no-cache git ca-certificates # Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer COPY go.mod ./ -RUN go get github.com/go-chi/chi/v5@v5.2.0 \ +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go get github.com/go-chi/chi/v5@v5.2.0 \ github.com/go-chi/cors@v1.2.1 \ github.com/golang-jwt/jwt/v5@v5.2.1 \ github.com/gorilla/websocket@v1.5.3 \ @@ -38,10 +45,16 @@ COPY cmd/ ./cmd/ COPY internal/ ./internal/ COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist +ARG SKIP_TESTS=0 # Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary -RUN go mod tidy && \ - CGO_ENABLED=0 go vet ./... && \ - CGO_ENABLED=0 go test -v ./... && \ +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go mod tidy && \ + if [ "$SKIP_TESTS" != "1" ]; then \ + CGO_ENABLED=0 go vet ./... && \ + CGO_ENABLED=0 go test -v ./...; \ + else \ + echo "Skipping backend tests (SKIP_TESTS=1)"; \ + fi && \ CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server # ==============================================================================