OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / ci / ci-runner.js

181 lines · javascript ref:
/**
 * 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)
    })
  })
}