version: '3' vars: BINARY: renewcms DIST_DIR: internal/infrastructure/assets/dist VERSION: sh: git describe --tags --always --dirty LDFLAGS: -s -w -X main.version={{.VERSION}} RELEASE_TARGETS: [windows/amd64, windows/arm64, linux/amd64, linux/arm64, darwin/amd64, darwin/arm64] env: CGO_ENABLED: '0' __COMPAT_LAYER: RunAsInvoker tasks: default: cmds: - task --list silent: true setup: desc: Install dev tools, Go modules and frontend dependencies cmds: - go install github.com/air-verse/air@latest - go install github.com/go-gremlins/gremlins/cmd/gremlins@latest - go mod download - task: web:install dev: desc: Run the API (air, hot reload) and the SPA (Vite, HMR) together → http://localhost:5173 deps: [dev:api, dev:web] dev:api: desc: Run the API with hot reload on :8080 (SPA not embedded) cmds: - air dev:web: desc: Run the Vite dev server on :5173, proxying /v1 to the API dir: web deps: [web:install] cmds: - bun dev test: desc: Run the Go tests cmds: - go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}} test:mysql: desc: Run the MySQL tests against real servers (mysql:8.4 then mariadb:11 in Docker, removed afterwards) cmds: - cmd: | PASSWORD=renewcms-test-root STATUS=0 run_engine() { NAME="renewcms-test-$1" IMAGE="$2" docker rm -f "$NAME" >/dev/null 2>&1 docker run -d --name "$NAME" -e MYSQL_ROOT_PASSWORD="$PASSWORD" -p 127.0.0.1::3306 "$IMAGE" >/dev/null || return 1 MAPPING="$(docker port "$NAME" 3306/tcp)" PORT="${MAPPING##*:}" READY=no TRIES=0 while [ "$TRIES" -lt 90 ]; do if docker exec "$NAME" sh -c "\$(command -v mariadb || command -v mysql) -h127.0.0.1 -uroot -p$PASSWORD -e 'SELECT 1'" >/dev/null 2>&1; then READY=yes break fi TRIES=$((TRIES + 1)) sleep 2 done if [ "$READY" != yes ]; then echo "$IMAGE did not become ready" >&2 docker logs "$NAME" 2>&1 | tail -n 20 >&2 docker rm -f "$NAME" >/dev/null 2>&1 return 1 fi echo "== $IMAGE on 127.0.0.1:$PORT" RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run MySQL -count=1 {{.CLI_ARGS}} RESULT=$? docker rm -f "$NAME" >/dev/null 2>&1 return $RESULT } run_engine mysql mysql:8.4 || STATUS=1 run_engine mariadb mariadb:11 || STATUS=1 exit $STATUS silent: true test:postgres: desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards) cmds: - cmd: | PASSWORD=renewcms-test-postgres STATUS=0 run_engine() { NAME="renewcms-test-postgres-$1" IMAGE="postgres:$1" docker rm -f "$NAME" >/dev/null 2>&1 docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1 MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)" PORT="${MAPPING##*:}" READY=no TRIES=0 while [ "$TRIES" -lt 90 ]; do if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then READY=yes break fi TRIES=$((TRIES + 1)) sleep 2 done if [ "$READY" != yes ]; then echo "$IMAGE did not become ready" >&2 docker logs "$NAME" 2>&1 | tail -n 20 >&2 docker rm -f "$NAME" >/dev/null 2>&1 return 1 fi echo "== $IMAGE on 127.0.0.1:$PORT" RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}} RESULT=$? docker rm -f "$NAME" >/dev/null 2>&1 return $RESULT } run_engine 16 || STATUS=1 run_engine 17 || STATUS=1 exit $STATUS silent: true test:s3: desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable) cmds: - cmd: | if ! docker info >/dev/null 2>&1; then echo "Docker is not available, skipping the S3 integration test" exit 0 fi export MSYS_NO_PATHCONV=1 NAME=renewcms-test-seaweedfs IMAGE=chrislusf/seaweedfs:4.48 BUCKET=renewcms-test ACCESS_KEY=renewcms-test SECRET_KEY=renewcms-test-secret docker rm -f "$NAME" >/dev/null 2>&1 docker run -d --name "$NAME" -e AWS_ACCESS_KEY_ID="$ACCESS_KEY" -e AWS_SECRET_ACCESS_KEY="$SECRET_KEY" -p 127.0.0.1::8333 "$IMAGE" server -dir=/data -s3 -s3.port=8333 >/dev/null || exit 1 MAPPING="$(docker port "$NAME" 8333/tcp | head -n 1)" PORT="${MAPPING##*:}" READY=no TRIES=0 while [ "$TRIES" -lt 60 ]; do if docker run --rm --network "container:$NAME" --entrypoint sh "$IMAGE" -c "echo 's3.bucket.create -name $BUCKET' | timeout 30 weed shell -master=127.0.0.1:9333 && echo s3.bucket.list | timeout 30 weed shell -master=127.0.0.1:9333 | grep -q $BUCKET" >/dev/null 2>&1; then READY=yes break fi TRIES=$((TRIES + 1)) sleep 2 done if [ "$READY" != yes ]; then echo "SeaweedFS did not become ready" >&2 docker logs "$NAME" 2>&1 | tail -n 20 >&2 docker rm -f "$NAME" >/dev/null 2>&1 exit 1 fi echo "== SeaweedFS on 127.0.0.1:$PORT" RENEWCMS_TEST_S3_ENDPOINT="http://127.0.0.1:$PORT" RENEWCMS_TEST_S3_BUCKET="$BUCKET" RENEWCMS_TEST_S3_ACCESS_KEY="$ACCESS_KEY" RENEWCMS_TEST_S3_SECRET_KEY="$SECRET_KEY" go test -tags nofrontend ./test/... -run S3StorageIntegration -count=1 -v {{.CLI_ARGS}} RESULT=$? docker rm -f "$NAME" >/dev/null 2>&1 exit $RESULT silent: true cover: desc: Go test coverage of the use cases → tmp/coverage.out cmds: - mkdir -p tmp - go test -tags nofrontend ./test/... -count=1 -coverpkg=./internal/infrastructure/useCases/... -coverprofile=tmp/coverage.out - go tool cover -func=tmp/coverage.out mutate: desc: Gremlins mutation testing of the use cases → tmp/gremlins.json cmds: - bash scripts/mutate.sh {{.CLI_ARGS}} test:web: desc: Run the SPA test suite (Vitest) in run mode dir: web deps: [web:install] cmds: - bun run vitest run visual:check: desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual// dir: web deps: [web:install] cmds: - bun visual/check.ts {{.CLI_ARGS}} lint: desc: gofmt check, go vet, eslint (--fix) and vue-tsc cmds: - task: lint:go - task: lint:web lint:go: cmds: - cmd: test -z "$(gofmt -l .)" || (gofmt -l . && echo "unformatted Go files, run 'task fmt'" && exit 1) silent: true - go vet -tags nofrontend ./... lint:web: dir: web deps: [web:install] cmds: - bun lint - bun run type-check fmt: desc: Format Go and frontend sources cmds: - gofmt -w . - bun run --cwd web format web:install: internal: true dir: web sources: [package.json, bun.lock] generates: ['node_modules/.bin/vite*'] cmds: - bun install --frozen-lockfile build:web: desc: Type-check and build the SPA into {{.DIST_DIR}} dir: web deps: [web:install] sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock] generates: ['../{{.DIST_DIR}}/index.html'] cmds: - bun run build build: desc: Production binary for this machine → bin/{{.BINARY}} deps: [build:web] cmds: - go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server release: desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY) cmds: - task: release:check - task: build:web - for: { var: RELEASE_TARGETS } task: build:target vars: GOOS: '{{index (splitList "/" .ITEM) 0}}' GOARCH: '{{index (splitList "/" .ITEM) 1}}' - go run ./cmd/releasesign checksums bin --match "{{.BINARY}}_{{.VERSION}}_*" - go run ./cmd/releasesign sign bin/checksums.txt - go run ./cmd/releasesign verify bin/checksums.txt release:check: internal: true cmds: - cmd: 'test -n "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY is not set. Point it at the private release key file (see task release:keygen)." >&2; exit 1; }' silent: true - cmd: 'test -f "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY points to a missing file: $RELEASE_SIGNING_KEY" >&2; exit 1; }' silent: true - mkdir -p tmp - echo probe > tmp/release-probe.txt - defer: rm -f tmp/release-probe.txt tmp/release-probe.txt.sig - go run ./cmd/releasesign sign tmp/release-probe.txt release:keygen: desc: One-time release key pair. Usage, task release:keygen -- cmds: - cmd: 'test -n "{{.CLI_ARGS}}" || { echo "usage: task release:keygen -- " >&2; exit 1; }' silent: true - go run ./cmd/releasesign keygen {{.CLI_ARGS}} build:target: internal: true vars: EXT: '{{if eq .GOOS "windows"}}.exe{{end}}' env: GOOS: '{{.GOOS}}' GOARCH: '{{.GOARCH}}' cmds: - go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}_{{.VERSION}}_{{.GOOS}}_{{.GOARCH}}{{.EXT}} ./cmd/server run: desc: Build and run the production binary locally deps: [build] cmds: - ./bin/{{.BINARY}}{{exeExt}} docker:build: desc: Build the Docker image tagged with the git describe version and latest cmds: - docker build --build-arg VERSION={{.VERSION}} -t renewcms:{{.VERSION}} -t renewcms:latest . docker:run: desc: Run the image once on a named volume, reading docker.env, on http://localhost:8080 cmds: - docker run --rm --name renewcms -p 8080:8080 --env-file docker.env -e DB_FILE=/data/renewcms.db -e UPLOAD_DIR=/data/uploads -v renewcms-data:/data --stop-timeout 15 renewcms:latest docker:up: desc: Build from the local sources and start the Compose service in the background env: VERSION: '{{.VERSION}}' cmds: - docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build docker:pull: desc: Pull the published image (RENEWCMS_IMAGE, default the beta tag) cmds: - docker compose pull docker:upgrade: desc: Pull the published image and recreate the Compose service on it cmds: - docker compose pull - docker compose up -d docker:down: desc: Stop and remove the Compose service (the data volume is kept) cmds: - docker compose down docker:logs: desc: Follow the Compose service logs cmds: - docker compose logs -f renewcms docker:publish: desc: 'Build and push the multi-arch image to the registry. Usage, task docker:publish [-- --dry-run]' vars: REGISTRY_IMAGE: sh: echo "${REGISTRY_IMAGE-git.floriansylvain.fr/fsylvain/renewcms}" cmds: - cmd: | IMAGE='{{.REGISTRY_IMAGE}}' V='{{.VERSION}}' ARGS='{{.CLI_ARGS}}' fail() { echo "docker:publish refused: $1" >&2; exit 1; } DRY=no case "$ARGS" in "") ;; "--dry-run") DRY=yes ;; *) fail "unknown argument '$ARGS' (only --dry-run is accepted)" ;; esac [ -n "$IMAGE" ] || fail "REGISTRY_IMAGE is empty" case "$V" in v*) ;; *) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-
" ;;
          esac
          R="${V#v}"
          CORE="${R%%-*}"
          PRE=""
          [ "$CORE" = "$R" ] || PRE="${R#*-}"
          MAJOR="${CORE%%.*}"
          REST="${CORE#*.}"
          MINOR="${REST%%.*}"
          PATCH="${REST#*.}"
          for N in "$MAJOR" "$MINOR" "$PATCH"; do
            case "$N" in
              ""|*[!0-9]*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-
" ;;
            esac
          done
          [ "$CORE" = "$MAJOR.$MINOR.$PATCH" ] || fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-
"
          if [ "$R" != "$CORE" ]; then
            case "$PRE" in
              ""|*[!0-9A-Za-z.-]*) fail "VERSION '$V' has an invalid pre-release part" ;;
            esac
            PRENAME="${PRE%%[!A-Za-z]*}"
            [ -n "$PRENAME" ] || fail "VERSION '$V' pre-release must start with letters, for example -beta or -rc.1"
          fi
          if [ "$DRY" = no ]; then
            [ -z "$(git status --porcelain)" ] || fail "the work tree has uncommitted changes"
            [ "$(git describe --tags --exact-match 2>/dev/null)" = "$V" ] || fail "VERSION '$V' is not an exact git tag on HEAD (an override is accepted only with --dry-run)"
          fi
          TAGS="$V $R"
          if [ -n "$PRE" ]; then
            TAGS="$TAGS $PRENAME"
          else
            TAGS="$TAGS $MAJOR.$MINOR latest"
          fi
          ARGS_TAGS=""
          LIST=""
          for T in $TAGS; do
            ARGS_TAGS="$ARGS_TAGS -t $IMAGE:$T"
            LIST="$LIST, $T"
          done
          echo "image: $IMAGE"
          echo "tags: ${LIST#, }"
          if [ "$DRY" = yes ]; then
            echo "dry run: building for the host platform only, loading locally, pushing nothing"
            docker buildx build --load --build-arg VERSION="$V" $ARGS_TAGS .
            exit 0
          fi
          BUILDER="$(docker buildx inspect 2>&1)" || fail "docker buildx is not available or no builder is selected (see docs/docker.md, Maintainer: publishing)"
          STORE="$(docker info 2>&1)"
          case "$BUILDER" in
            *linux/amd64*) ;;
            *) fail "the selected buildx builder does not support linux/amd64 (see docs/docker.md, Maintainer: publishing)" ;;
          esac
          case "$BUILDER" in
            *linux/arm64*) ;;
            *) fail "the selected buildx builder does not support linux/arm64: enable QEMU/binfmt (see docs/docker.md, Maintainer: publishing)" ;;
          esac
          case "$BUILDER" in
            *"Driver:"*"docker-container"*|*"Driver:"*"kubernetes"*|*"Driver:"*"remote"*) ;;
            *)
              case "$STORE" in
                *io.containerd.snapshotter*) ;;
                *) fail "the selected builder uses the plain docker driver without the containerd image store, which cannot build multi-platform images: run 'docker buildx create --use' (see docs/docker.md, Maintainer: publishing)" ;;
              esac
              ;;
          esac
          docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$V" $ARGS_TAGS --push .
        silent: true

  clean:
    desc: Remove build output, the built SPA and tmp/
    cmds:
      - rm -rf bin tmp {{.DIST_DIR}}