diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..568b768
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,56 @@
+name: CI
+
+# Tests y smoke. El smoke arranca el servidor de verdad, comprueba health y
+# versión, crea datos, hace un backup con el CLI del timer y verifica que se
+# puede restaurar. Sin systemd ni root: en CI no hay systemd.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ cache-dependency-path: server/package-lock.json
+
+ - name: Install server dependencies
+ working-directory: server
+ run: npm ci
+
+ - name: Server tests
+ working-directory: server
+ run: npm test
+
+ - name: Smoke (server, backup and restore)
+ working-directory: server
+ run: node scripts/ci-smoke.mjs
+
+ installer:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Shell scripts parse
+ run: |
+ sh -n install.sh
+ for f in deploy/*.sh; do sh -n "$f"; done
+
+ # Descarga la última release, verifica su checksum y recorre los pasos sin
+ # tocar el sistema. Falla si el manifiesto, el checksum o los pasos rompen.
+ - name: install.sh dry-run
+ run: sh install.sh --dry-run
diff --git a/server/scripts/ci-smoke.mjs b/server/scripts/ci-smoke.mjs
new file mode 100644
index 0000000..8d8eb6c
--- /dev/null
+++ b/server/scripts/ci-smoke.mjs
@@ -0,0 +1,165 @@
+// ci-smoke.mjs — smoke de CI: arranca el servidor, comprueba health y versión,
+// crea datos, hace un backup con el CLI del timer y verifica que se puede
+// restaurar. No necesita systemd ni root.
+//
+// Uso (desde server/): node scripts/ci-smoke.mjs
+import { spawn } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import Database from 'better-sqlite3'
+
+const serverDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const PORT = 3210
+const BASE = `http://127.0.0.1:${PORT}`
+const AUTH = { username: 'smoke', password: 'smoke-secret' }
+
+let failed = false
+const fail = (m) => {
+ console.error(`FAIL: ${m}`)
+ failed = true
+}
+const ok = (m) => console.log(`ok: ${m}`)
+
+const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deltos-smoke-'))
+const staticDir = path.join(dataDir, 'dist')
+fs.mkdirSync(staticDir, { recursive: true })
+fs.writeFileSync(path.join(staticDir, 'index.html'), '
smoke')
+
+const server = spawn(process.execPath, ['src/index.js'], {
+ cwd: serverDir,
+ env: {
+ ...process.env,
+ PORT: String(PORT),
+ DATA_DIR: dataDir,
+ STATIC_DIR: staticDir,
+ AUTH_USER: AUTH.username,
+ AUTH_PASS: AUTH.password,
+ },
+ stdio: ['ignore', 'inherit', 'inherit'],
+})
+
+async function waitHealth(timeoutMs = 25000) {
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const res = await fetch(`${BASE}/health`)
+ if (res.status === 200) return true
+ } catch {
+ /* aún no levanta */
+ }
+ await new Promise((r) => setTimeout(r, 300))
+ }
+ return false
+}
+
+async function api(pathname, { method = 'GET', cookie, csrf, body } = {}) {
+ const res = await fetch(BASE + pathname, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(cookie ? { cookie } : {}),
+ ...(csrf ? { 'x-csrf-token': csrf } : {}),
+ },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ })
+ const text = await res.text()
+ let json = null
+ try {
+ json = text ? JSON.parse(text) : null
+ } catch {
+ /* respuesta no JSON */
+ }
+ return { status: res.status, json, setCookie: res.headers.get('set-cookie') }
+}
+
+function stopServer() {
+ return new Promise((resolve) => {
+ server.once('exit', resolve)
+ server.kill('SIGTERM')
+ setTimeout(() => {
+ server.kill('SIGKILL')
+ resolve()
+ }, 5000)
+ })
+}
+
+// --- 1. Arranque, health y versión ---
+if (!(await waitHealth())) {
+ fail('el servidor no respondió a /health')
+ await stopServer()
+ fs.rmSync(dataDir, { recursive: true, force: true })
+ process.exit(1)
+}
+ok('health 200')
+
+const pkg = JSON.parse(fs.readFileSync(path.join(serverDir, 'package.json'), 'utf8'))
+const login = await api('/api/auth/login', { method: 'POST', body: AUTH })
+if (login.status !== 200) {
+ fail(`login devolvió ${login.status}`)
+} else {
+ const cookie = login.setCookie.split(';')[0]
+ const csrf = login.json.csrfToken
+
+ const version = await api('/api/version', { cookie })
+ if (version.json?.version !== pkg.version) fail(`versión ${version.json?.version} != ${pkg.version}`)
+ else ok(`versión ${pkg.version}`)
+
+ const proj = await api('/api/projects', {
+ method: 'POST',
+ cookie,
+ csrf,
+ body: { name: 'Smoke', emoji: '', color: 'sky' },
+ })
+ if (proj.status !== 201) fail(`crear proyecto devolvió ${proj.status}`)
+ const task = await api('/api/tasks', {
+ method: 'POST',
+ cookie,
+ csrf,
+ body: { project_id: proj.json?.project?.id, title: 'Smoke task' },
+ })
+ if (task.status !== 201) fail(`crear tarea devolvió ${task.status}`)
+ else ok('datos creados')
+}
+
+await stopServer()
+
+// --- 2. Backup por el CLI (la ruta del timer) ---
+const backup = spawn(process.execPath, ['src/backup-cli.js', dataDir], {
+ cwd: serverDir,
+ stdio: 'inherit',
+})
+const backupCode = await new Promise((r) => backup.on('exit', r))
+if (backupCode !== 0) fail(`backup-cli salió con ${backupCode}`)
+
+const backupsDir = path.join(dataDir, 'backups')
+const backups = fs.existsSync(backupsDir)
+ ? fs.readdirSync(backupsDir).filter((f) => f.endsWith('.db'))
+ : []
+if (backups.length !== 1) fail(`se esperaba 1 backup, hay ${backups.length}`)
+else ok('backup creado')
+
+// --- 3. Restore sobre una BD vacía ---
+if (backups.length === 1) {
+ const dbPath = path.join(dataDir, 'app.db')
+ for (const suffix of ['', '-wal', '-shm']) fs.rmSync(dbPath + suffix, { force: true })
+ fs.copyFileSync(path.join(backupsDir, backups[0]), dbPath)
+
+ const db = new Database(dbPath, { readonly: true })
+ const integrity = db.pragma('integrity_check', { simple: true })
+ const tasks = db.prepare('SELECT COUNT(*) AS n FROM tasks').get().n
+ db.close()
+
+ if (integrity !== 'ok') fail(`integrity_check: ${integrity}`)
+ else ok('integrity_check ok')
+ if (tasks !== 1) fail(`tareas tras restaurar: ${tasks} (se esperaba 1)`)
+ else ok('el restore conserva las tareas')
+}
+
+fs.rmSync(dataDir, { recursive: true, force: true })
+if (failed) {
+ console.error('SMOKE FALLÓ')
+ process.exit(1)
+}
+console.log('SMOKE OK')