name: CI on: push: branches: [lotus] pull_request: branches: [lotus] # Only the newest commit per ref needs to build: a superseded push cancels its # in-flight run. This keeps the shared act_runner free (web CI otherwise queues # behind long Tauri desktop builds) and — since `trigger-desktop` is `needs: # build` — means only the latest lotus commit ever kicks a desktop build, # instead of one per rapid push. Cancelling a superseded run is deploy-safe # ONLY because lotus_deploy.sh re-resolves origin/lotus each poll iteration and # retargets its CI gate to HEAD — otherwise a run cancelled mid-poll would # strand the newest commit undeployed. Keep those two in sync. concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: name: Build & Quality Checks runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.node-version' # No npm / node_modules cache: the act_runner's internal cache server is # unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT # 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure # cost, zero benefit. `cache: npm` was removed from Setup Node above for the # same reason. Re-enable both (setup-node `cache: npm` + an actions/cache # node_modules step) once the runner's cache server is reachable from jobs. - name: Install dependencies # Harden against transient registry network failures (ECONNRESET etc.): # raise npm's built-in fetch retries/timeouts and retry `npm ci` up to # 3 times with backoff before failing the build. run: | npm config set fetch-retries 5 npm config set fetch-retry-mintimeout 20000 npm config set fetch-retry-maxtimeout 120000 npm config set fetch-timeout 600000 for attempt in 1 2 3; do echo "npm ci attempt $attempt…" npm ci && break if [ "$attempt" = "3" ]; then echo "npm ci failed after 3 attempts" >&2 exit 1 fi echo "npm ci failed; retrying in $((attempt * 15))s…" >&2 sleep $((attempt * 15)) done # ── #99 — lockfile.yml (GitHub-only, deleted) commented on lockfile # diffs after the fact; this is the CI-native equivalent, ported as a # hard gate. `npm ci` already refuses to run on an out-of-range # mismatch, but it can silently normalize lesser lockfile drift (e.g. # metadata/resolved fields behind a stale-but-satisfiable range) # without failing — so assert zero diff afterward, which is the # cheapest way to also catch that class of drift. - name: Verify lockfile is in sync run: git diff --exit-code package-lock.json # ── Quality gates run BEFORE the slow build so a format/lint/type/test # error fails in seconds instead of after the ~minutes-long build. All are # hard gates — any failure fails the job and blocks the deploy. The tree is # held clean (prettier formatted, eslint 0 errors, typecheck 0), so these # gate real regressions. NOTE: the lotus-build.sh upstream-merge path can # deploy without CI; a later normal push surfaces any introduced issue here # — fix forward (or briefly re-soften a gate) rather than deploy broken. # eslint gates on errors, plus a warning ratchet (Gitea #97): `check:eslint` # runs with `--max-warnings 74`, the exact warning count on this tree at # the time the ratchet was added. New warnings push the count over that # ceiling and fail the build; fixing an existing warning is free to do # and should lower the ceiling in the same PR so the count can only go # down over time, never back up. - name: Prettier run: npm run check:prettier - name: ESLint run: npm run check:eslint - name: TypeScript run: npm run typecheck # Deterministic pure-logic tests on Node's built-in runner via tsx (no # vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy. - name: Unit tests run: npm test # ── Critical gate — if this fails, nothing deploys. Produces dist/. ── - name: Build run: npm run build env: NODE_OPTIONS: '--max_old_space_size=4096' VITE_APP_VERSION: ${{ github.sha }} # ── Boot check — actually loads the built dist/, not just builds it ── - name: Boot check run: node scripts/boot-check.mjs # ── Security — hard gate. #24 cleared the outstanding advisories (0 # vulnerabilities on this tree, verified with `npm audit --omit=dev`), so # there is nothing left this should be soft against. Hard on both # `push` and `pull_request`: a new high/critical advisory should block # the deploy just as much as it should block the PR. - name: Audit (high/critical) run: npm audit --audit-level=high --omit=dev # ── Bundle size budget — hard gate on pull_request, warning on push (a # push has already merged; failing it can only delay deploying an # otherwise-good commit, not prevent the regression, so pull_request is # where this should be caught). Budgets live in scripts/bundle-budget.json. - name: Check bundle size budget continue-on-error: ${{ github.event_name == 'push' }} run: node scripts/check-bundle-size.mjs ${{ github.event_name }} # ── Desktop build trigger ────────────────────────────────────────────── # Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or # `npm run build`) never bumps the cinny-desktop submodule and kicks off the # slow Tauri release builds, which would only error out downstream. Only # runs on a real push to lotus — not on pull_request CI runs. trigger-desktop: name: Trigger Desktop Build needs: build if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }} runs-on: ubuntu-latest steps: - name: Bump cinny submodule env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | CINNY_SHA="${{ github.sha }}" git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop cd desktop git config user.email "ci@lotusguild.org" git config user.name "Lotus CI" git submodule update --init cinny git -C cinny fetch origin git -C cinny checkout "$CINNY_SHA" git add cinny if git diff --cached --quiet; then echo "Submodule already at $CINNY_SHA, nothing to do" else git commit -m "chore: bump cinny submodule to ${CINNY_SHA:0:8}" git push origin main echo "Pushed — cinny-desktop release.yml will start via on:push trigger" fi # ── #95 — secret scanning ──────────────────────────────────────────────── # zricethezav/gitleaks-action is GitHub-Actions-only; on the Gitea act_runner # we can't assume the host has a Docker daemon reachable from job containers # (see the `docker` job below), so this downloads the pinned linux/amd64 # binary release directly instead. `--no-git` scans the checked-out tree as # plain files (a point-in-time content scan) rather than walking history, # since this runs on both push and pull_request and a PR's shallow checkout # doesn't carry full history anyway. gitleaks: name: Secret scan (gitleaks) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install gitleaks 8.30.1 run: | curl -fsSL -o gitleaks.tar.gz \ https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz tar -xzf gitleaks.tar.gz gitleaks chmod +x gitleaks - name: Scan for secrets run: ./gitleaks detect --no-git -v --redact --source . --config .gitleaks.toml # ── #93 — the image was never actually built in CI, so a Dockerfile break # (or a header regression, once #95's nginx CSP shipped) could sit unnoticed # until a manual `docker build` on deploy infra caught it. This builds the # real image, boots it, and asserts both a 200 and the security headers # added to docker-nginx.conf for #95. # # `continue-on-error: true` — informational for now. The shared act_runner # may not expose a Docker daemon to job containers (same class of problem # as the unreachable cache server noted above); flip this off once it's # confirmed the runner can actually run `docker build`/`docker run` here. docker: name: Docker image build & smoke test needs: build runs-on: ubuntu-latest continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 - name: Build image run: docker build -t cinny-ci . - name: Run container run: docker run -d --name cinny-ci -p 8095:80 cinny-ci - name: Wait for container to be ready run: | for i in $(seq 1 30); do curl -fsS -o /dev/null http://localhost:8095/ && exit 0 sleep 1 done echo "container never became ready" >&2 exit 1 - name: Check response and security headers run: | headers="$(curl -fsSI http://localhost:8095/)" echo "$headers" echo "$headers" | grep -qi '^HTTP/[0-9.]* 200' || { echo "expected HTTP 200"; exit 1; } echo "$headers" | grep -qi '^content-security-policy:' || { echo "missing Content-Security-Policy header"; exit 1; } echo "$headers" | grep -qi "frame-ancestors 'none'" || { echo "CSP missing frame-ancestors 'none'"; exit 1; } echo "$headers" | grep -qi '^referrer-policy: *no-referrer' || { echo "missing Referrer-Policy header"; exit 1; } echo "$headers" | grep -qi '^x-content-type-options: *nosniff' || { echo "missing X-Content-Type-Options header"; exit 1; } - name: Stop container if: always() run: docker rm -f cinny-ci || true # ── #90 — Playwright smoke test ────────────────────────────────────────── # Boots the built client in a real (headless) Chromium and drives it. Two # tiers live under e2e/ (see LOTUS_TESTING.md → "Playwright smoke test"): # boot tier — always runs: login page renders with no console/page # errors, sw.js is served + registers, bundled Element Call # mounts in a frame. # E2EE tier — password login, create a private encrypted room, send text # + a compressed image, assert every `PUT …/send/*` went out # as m.room.encrypted. Skips itself unless the E2E_* secrets # below are set (create them under repo → Settings → Actions # → Secrets; they are empty until then). # dist/ is rebuilt in-job because actions/upload-artifact@v4 does not work # on this Gitea runner (LOTUS_TODO), so `needs: build` only gates on the # main job having passed, not on its artifact. # continue-on-error: `playwright install --with-deps` needs apt on the # runner image; promote to hard once green on the runner. e2e: name: Playwright smoke (e2e) needs: build runs-on: ubuntu-latest continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.node-version' - name: Install dependencies run: | npm config set fetch-retries 5 npm config set fetch-retry-mintimeout 20000 npm config set fetch-retry-maxtimeout 120000 npm config set fetch-timeout 600000 for attempt in 1 2 3; do echo "npm ci attempt $attempt…" npm ci && break if [ "$attempt" = "3" ]; then echo "npm ci failed after 3 attempts" >&2 exit 1 fi echo "npm ci failed; retrying in $((attempt * 15))s…" >&2 sleep $((attempt * 15)) done - name: Install Playwright Chromium run: npx playwright install --with-deps chromium - name: Build run: npm run build env: NODE_OPTIONS: '--max_old_space_size=6144' VITE_APP_VERSION: ${{ github.sha }} - name: Playwright smoke test run: npm run test:e2e env: CI: 'true' E2E_HOMESERVER: ${{ secrets.E2E_HOMESERVER }} E2E_USER: ${{ secrets.E2E_USER }} E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}