name: CI # Runs on every branch and pull request. Publishing lives in release.yml and # only runs once this workflow has passed. # # main is excluded from the push trigger on purpose: release.yml already calls # this workflow there. Running both would put two jobs in the same concurrency # group, and one would cancel the other - which is why every merge used to # leave a cancelled CI run behind. on: push: branches-ignore: [main] pull_request: branches: [main] workflow_call: concurrency: group: ci-${{ gitea.ref }} cancel-in-progress: true jobs: lang-lint: name: Lint de idioma (R9) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: node scripts/lang-lint.mjs go-protocols: name: Go (protocols) runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_DB: lambdamail_test POSTGRES_USER: lambdamail_test POSTGRES_PASSWORD: lambdamail_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 defaults: run: working-directory: services/protocols steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version-file: services/protocols/go.mod # defaults.run.working-directory applies to run steps, not to # actions, so setup-go looks for go.sum at the repository root and # silently skips its cache without this. cache-dependency-path: services/protocols/go.sum - name: Run migrations run: | # The job's workspace is a volume of its own, invisible to the Docker # daemon the job talks to, so `-v "$PWD:..."` would mount an empty # directory. Files are copied into the container instead. cid=$(docker create --network host migrate/migrate:v4.17.1 \ -path=/migrations \ -database "postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable" \ up) docker cp "$(pwd)/../../migrations/." "$cid:/migrations" docker start -a "$cid" status=$(docker inspect -f '{{.State.ExitCode}}' "$cid") docker rm "$cid" >/dev/null exit "$status" - run: go vet ./... # Module and build caches live under the unprivileged user's home, which # setup-go's cache does not cover, and the embedded Postgres binaries are # otherwise downloaded again by every test that starts one. - uses: actions/cache@v4 with: path: | /home/tester/go/pkg/mod /home/tester/.cache/go-build /home/tester/.embedded-postgres-go key: go-tester-${{ runner.arch }}-${{ hashFiles('services/protocols/go.sum') }} restore-keys: go-tester-${{ runner.arch }}- # The end-to-end suites start an embedded Postgres, and initdb refuses to # run as root, which is what a job container is. Run the tests as a # throwaway unprivileged user (GitHub's hosted runners already are one). - name: Testes env: TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable run: | id tester >/dev/null 2>&1 || useradd -m -u 1001 tester chown -R tester:tester "$GITHUB_WORKSPACE" /home/tester runuser -u tester -- env "HOME=/home/tester" "PATH=$PATH" \ "TEST_DATABASE_URL=$TEST_DATABASE_URL" go test ./... -v ts-auth: name: TypeScript (auth) runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_DB: lambdamail_test POSTGRES_USER: lambdamail_test POSTGRES_PASSWORD: lambdamail_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10 defaults: run: working-directory: services/auth steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: "20" cache: npm cache-dependency-path: services/auth/package-lock.json # The auth suite asserts against real rows: password verification, # lockout, TOTP replay and recovery-code consumption are all decided in # the database, so a mock would prove nothing. - name: Run migrations run: | # The job's workspace is a volume of its own, invisible to the Docker # daemon the job talks to, so `-v "$PWD:..."` would mount an empty # directory. Files are copied into the container instead. cid=$(docker create --network host migrate/migrate:v4.17.1 \ -path=/migrations \ -database "postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable" \ up) docker cp "$(pwd)/../../migrations/." "$cid:/migrations" docker start -a "$cid" status=$(docker inspect -f '{{.State.ExitCode}}' "$cid") docker rm "$cid" >/dev/null exit "$status" - run: npm install - run: npm run lint - run: npm test env: TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable LAMBDAMAIL_MASTER_KEY: ci-master-key-at-least-16-chars ts-storage: name: TypeScript (storage) runs-on: ubuntu-latest defaults: run: working-directory: services/storage steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: "20" cache: npm cache-dependency-path: services/storage/package-lock.json - run: npm install - run: npm run lint - run: npm test webmail-build: name: Testes (webmail) runs-on: ubuntu-latest defaults: run: working-directory: services/webmail steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: "20" cache: npm cache-dependency-path: services/webmail/package-lock.json - run: npm install - run: npm run lint - run: npm test # No `npm run build` here: the webmail image job runs the same Next build # inside its Dockerfile, so building it twice only doubles the wait. security: name: Trivy (dependências, segredos e Dockerfiles) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # A mail server holds credentials for every mailbox it serves, so the # secret scanner matters as much as the vulnerability one here: a DKIM # key or a Cloudflare token committed by accident is a full compromise. # # Trivy runs from its official image rather than aquasecurity/trivy-action: # that action clones the Trivy repository from the workflow's own server, # which on Gitea is not GitHub. - name: Repositório run: | cid=$(docker create --network host -v trivy-cache:/root/.cache/ -w /src aquasec/trivy:0.70.0 \ fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 \ --skip-dirs services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next .) docker cp . "$cid:/src" docker start -a "$cid" status=$(docker inspect -f '{{.State.ExitCode}}' "$cid") docker rm "$cid" >/dev/null exit "$status" # MEDIUM is reported but does not fail the build. # # The gate above stops at HIGH, which is why a MEDIUM XSS advisory in # DOMPurify - the library that sanitises every message body the webmail # renders - sat unnoticed through green builds. Severity is the # advisory's opinion, not this project's: a MEDIUM in the HTML sanitiser # matters more here than a HIGH in a build-time tool. # # Reporting rather than gating, because failing on every MEDIUM makes the # scanner something people learn to skip. - name: Repositório (MEDIUM, informativo) run: | cid=$(docker create --network host -v trivy-cache:/root/.cache/ -w /src aquasec/trivy:0.70.0 \ fs --scanners vuln,secret --severity MEDIUM --exit-code 0 \ --skip-dirs services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next .) docker cp . "$cid:/src" docker start -a "$cid" status=$(docker inspect -f '{{.State.ExitCode}}' "$cid") docker rm "$cid" >/dev/null exit "$status" image: name: Imagem (${{ matrix.service }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: service: [protocols, auth, storage, webmail, migrations, rspamd] steps: - uses: actions/checkout@v7 # Built but not pushed: this proves each Dockerfile still works on every # branch, while publishing stays in release.yml. # # Plain `docker build` on purpose. setup-buildx would start a fresh # BuildKit container per job and throw its layer cache away; the daemon's # own builder keeps the cache in the runner's persistent volume, and the # "Stack completa" job reuses it for the same Dockerfiles. - name: Build run: | case '${{ matrix.service }}' in migrations|rspamd) context=. ;; *) context=services/${{ matrix.service }} ;; esac docker build -f services/${{ matrix.service }}/Dockerfile \ -t lambdamail-${{ matrix.service }}:ci "$context" # Scanned only for the images whose contents we author. migrations and # rspamd add .sql and .conf files to an upstream image and nothing else, # so every finding in them belongs to that upstream project and cannot be # fixed here - gating on it would gate this pipeline on somebody else's # release cadence. They are still built above, and OPERATIONS.md records # that their base images have to be watched. - name: Trivy na imagem if: matrix.service != 'migrations' && matrix.service != 'rspamd' run: | docker run --rm --network host -v trivy-cache:/root/.cache/ -e DOCKER_HOST=tcp://127.0.0.1:2375 aquasec/trivy:0.70.0 \ image --severity HIGH,CRITICAL --exit-code 1 lambdamail-${{ matrix.service }}:ci compose-up: name: Stack completa runs-on: ubuntu-latest needs: [go-protocols, ts-auth, ts-storage, webmail-build] steps: - uses: actions/checkout@v7 - run: scripts/init-env.sh # The proxy network is declared external because Coolify owns it on the # deploy host. Nothing creates it here, and without it compose refuses to # start the stack at all. # # Idempotent on purpose: the runner's Docker daemon outlives the job, so # the network may already exist from an earlier run. - name: Cria a rede do proxy run: | net="${COOLIFY_PROXY_NETWORK:-coolify}" docker network inspect "$net" >/dev/null 2>&1 || docker network create "$net" # The build overlay is explicit here: CI has to exercise the code in the # branch, not the images already published from main. - run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml --env-file .env up --build --wait - run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml ps # Always, or a failed `up` leaves containers and volumes behind in the # daemon the next run starts from. - name: Derruba a stack if: always() run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml down -v