Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
165 changes: 165 additions & 0 deletions server/scripts/ci-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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'), '<!doctype html><title>smoke</title>')

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')
Loading