/**
* CI Runner - YAML-based build step execution for pear-git deploys
*
* Repos can define custom build steps in .pear-git/ci.yaml instead of
* relying on the default `npm install --production` fallback.
*/
import fs from 'bare-fs'
import path from 'bare-path'
import process from 'bare-process'
import subprocess from 'bare-subprocess'
import yaml from 'js-yaml'
const DEFAULT_TIMEOUT = 120000 // 120s per step
const MAX_OUTPUT = 1024 * 1024 // 1MB output cap per step
/**
* Load and validate a .pear-git/ci.yaml config from a checkout directory.
*
* @param {string} checkoutDir - Path to the cloned repo
* @returns {object|null} Parsed config with `steps` array, or null if no file
*/
export function loadCIConfig (checkoutDir) {
const configPath = path.join(checkoutDir, '.pear-git', 'ci.yaml')
let raw
try {
raw = fs.readFileSync(configPath, 'utf-8')
} catch {
return null // No ci.yaml — use default fallback
}
const config = yaml.load(raw, { schema: yaml.FAILSAFE_SCHEMA })
if (!config || typeof config !== 'object') {
throw new Error('ci.yaml must be a YAML object')
}
if (!Array.isArray(config.steps)) {
throw new Error('ci.yaml must have a "steps" array')
}
if (config.steps.length === 0) {
throw new Error('ci.yaml "steps" array must not be empty')
}
for (let i = 0; i < config.steps.length; i++) {
const step = config.steps[i]
if (!step || typeof step !== 'object') {
throw new Error(`Step ${i} must be an object`)
}
if (typeof step.name !== 'string' || step.name.length === 0) {
throw new Error(`Step ${i} must have a "name" string`)
}
if (typeof step.run !== 'string' || step.run.length === 0) {
throw new Error(`Step ${i} must have a "run" string`)
}
if (step.timeout !== undefined) {
const t = parseInt(step.timeout, 10)
if (isNaN(t) || t <= 0) {
throw new Error(`Step ${i} "timeout" must be a positive integer (seconds)`)
}
step.timeout = t
}
}
return config
}
/**
* Run CI steps sequentially. Aborts on first failure.
*
* @param {string} checkoutDir - Working directory for steps
* @param {object} config - Parsed ci.yaml config
* @param {object} opts - { repoName, channel }
* @returns {Array<{ name, status, duration, output, error }>}
*/
export async function runCISteps (checkoutDir, config, opts = {}) {
const results = []
const env = {
...process.env,
CI: 'true',
PEAR_GIT: 'true',
REPO_NAME: opts.repoName || '',
CHANNEL: opts.channel || ''
}
for (const step of config.steps) {
const timeoutMs = (step.timeout || 120) * 1000
const start = Date.now()
console.log(`[CI] Running step: ${step.name}`)
// Elapsed timer for long-running steps
const ticker = setInterval(() => {
const elapsed = ((Date.now() - start) / 1000).toFixed(0)
console.log(`[CI] ${step.name}: ${elapsed}s elapsed...`)
}, 15000)
try {
const output = await execStep(checkoutDir, step.run, { env, timeout: timeoutMs })
clearInterval(ticker)
const duration = Date.now() - start
results.push({
name: step.name,
status: 'ok',
duration,
output: output.slice(0, MAX_OUTPUT)
})
console.log(`[CI] Step "${step.name}" passed (${duration}ms)`)
} catch (err) {
const duration = Date.now() - start
results.push({
name: step.name,
status: 'failed',
duration,
output: '',
error: err.message
})
clearInterval(ticker)
console.error(`[CI] Step "${step.name}" FAILED: ${err.message}`)
throw new Error(`CI step "${step.name}" failed: ${err.message}`)
}
}
return results
}
/**
* Execute a single shell command via sh -c.
* Follows the existing execInDir pattern from deploy-hook.js.
*/
function execStep (cwd, command, opts = {}) {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn('sh', ['-c', command], {
cwd,
env: opts.env,
stdio: ['ignore', 'pipe', 'pipe']
})
const chunks = []
const errChunks = []
let outputSize = 0
const timeoutMs = opts.timeout || DEFAULT_TIMEOUT
const timeout = setTimeout(() => {
proc.kill('SIGTERM')
reject(new Error(`Timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
proc.stdout.on('data', (c) => {
outputSize += c.length
if (outputSize <= MAX_OUTPUT) chunks.push(c)
})
proc.stderr.on('data', (c) => {
outputSize += c.length
if (outputSize <= MAX_OUTPUT) errChunks.push(c)
})
proc.on('exit', (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(Buffer.concat(chunks).toString('utf-8'))
} else {
const stderr = Buffer.concat(errChunks).toString('utf-8')
reject(new Error(stderr || `Command exited with code ${code}`))
}
})
proc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}