OverviewFilesCommitsIssuesDocsReleasesPeers

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

355 lines · javascript ref:
/**
 * CI runner — parses `.pear-git/ci.yaml` and runs build steps.
 *
 * Ported from pear-git v1 (lib/ci/ci-runner.js) with v3 additions:
 *   - `artifacts:` top-level list naming files OR directories (relative
 *     to the checkout dir) that should be uploaded to the release dir
 *     on success. Directory entries are walked recursively; each leaf
 *     file becomes its own manifest entry with its in-tree path
 *     preserved. Symlinks are skipped to avoid escaping the checkout.
 *   - `channels:` map deciding which channel a build should publish to
 *     based on the pushed ref.
 *
 * Doctrine (from spec): this is a **release pipeline**, not CI. Steps
 * build artifacts; channels route them. No test matrix, no remote
 * runners, no parallel steps. Exit codes gate publish.
 *
 * YAML shape:
 *
 *   steps:
 *     - name: install
 *       run: npm ci
 *       timeout: 120
 *     - name: build
 *       run: npm run build
 *
 *   artifacts:
 *     - dist/app.tar.gz
 *     - dist/checksums.txt
 *
 *   channels:
 *     production:
 *       tags: ['v*']
 *     experimental:
 *       branches: ['main']
 */

import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { spawn } from '#node:child_process'
import yaml from 'js-yaml'

const DEFAULT_TIMEOUT_MS = 120_000
const MAX_OUTPUT_BYTES = 1024 * 1024  // 1MB per-step output cap

/**
 * Load and validate `.pear-git/ci.yaml` from a checkout directory.
 * Returns null if the file doesn't exist (i.e. the repo opts out of CI).
 */
export function loadCIConfig (checkoutDir) {
  const configPath = path.join(checkoutDir, '.pear-git', 'ci.yaml')
  let raw
  try {
    raw = fs.readFileSync(configPath, 'utf-8')
  } catch (err) {
    if (err.code === 'ENOENT') return null
    throw err
  }

  const config = yaml.load(raw, { schema: yaml.FAILSAFE_SCHEMA })
  if (!config || typeof config !== 'object') {
    throw new Error('ci.yaml must be a YAML mapping')
  }

  // steps: required, non-empty array of { name, run, timeout? }
  if (!Array.isArray(config.steps) || config.steps.length === 0) {
    throw new Error('ci.yaml must have a non-empty "steps" array')
  }
  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 a mapping`)
    if (typeof step.name !== 'string' || !step.name) throw new Error(`step ${i}: "name" must be a non-empty string`)
    if (typeof step.run !== 'string' || !step.run) throw new Error(`step ${i}: "run" must be a non-empty 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
    }
  }

  // artifacts: optional array of relative paths
  if (config.artifacts !== undefined) {
    if (!Array.isArray(config.artifacts)) {
      throw new Error('ci.yaml "artifacts" must be a list of relative paths')
    }
    for (const a of config.artifacts) {
      if (typeof a !== 'string' || !a) throw new Error('ci.yaml artifact entries must be non-empty strings')
      if (a.startsWith('/') || a.includes('..')) throw new Error('artifact paths must be relative + not escape the checkout: ' + a)
    }
  }

  // channels: optional map of channel → { tags?, branches? }
  if (config.channels !== undefined) {
    if (!config.channels || typeof config.channels !== 'object' || Array.isArray(config.channels)) {
      throw new Error('ci.yaml "channels" must be a mapping')
    }
    for (const [name, cfg] of Object.entries(config.channels)) {
      if (!cfg || typeof cfg !== 'object') throw new Error(`channels.${name} must be a mapping`)
      if (cfg.tags !== undefined && !Array.isArray(cfg.tags)) throw new Error(`channels.${name}.tags must be a list`)
      if (cfg.branches !== undefined && !Array.isArray(cfg.branches)) throw new Error(`channels.${name}.branches must be a list`)
    }
  }

  // vdrive: optional. `true` publishes every release into the repo's virtual
  // Hyperdrive; a mapping can restrict it to named channels. Normalised to
  // { enabled, channels } or null so callers don't re-parse.
  // NB: FAILSAFE_SCHEMA means every scalar arrives as a STRING.
  config.vdrive = normaliseVdrive(config.vdrive)

  return config
}

function normaliseVdrive (raw) {
  if (raw === undefined || raw === null) return null

  if (typeof raw === 'string') {
    const v = raw.trim().toLowerCase()
    if (v === 'true' || v === 'yes' || v === 'on') return { enabled: true, channels: null }
    if (v === 'false' || v === 'no' || v === 'off') return { enabled: false, channels: null }
    throw new Error('ci.yaml "vdrive" must be true/false or a mapping')
  }

  if (typeof raw === 'boolean') return { enabled: raw, channels: null }

  if (typeof raw !== 'object' || Array.isArray(raw)) {
    throw new Error('ci.yaml "vdrive" must be true/false or a mapping')
  }

  if (raw.channels !== undefined && !Array.isArray(raw.channels)) {
    throw new Error('ci.yaml "vdrive.channels" must be a list')
  }
  for (const c of raw.channels || []) {
    if (typeof c !== 'string' || !c) throw new Error('ci.yaml "vdrive.channels" entries must be strings')
  }

  return {
    enabled: true,
    channels: raw.channels ? raw.channels.slice() : null
  }
}

/**
 * Pick the channel this ref should publish to based on the ci.yaml rules.
 *
 * Ref shape is plain branch name ("main") or tag name ("v1.0.0"). Tag
 * matches win over branch matches. Returns null if nothing matches.
 */
export function pickChannel (config, ref, { isTag = false } = {}) {
  if (!config || !config.channels) return null
  // Tags take precedence — scan all channels' tags lists first
  if (isTag) {
    for (const [name, cfg] of Object.entries(config.channels)) {
      if (Array.isArray(cfg.tags) && matchesAny(ref, cfg.tags)) return name
    }
    return null
  }
  for (const [name, cfg] of Object.entries(config.channels)) {
    if (Array.isArray(cfg.branches) && matchesAny(ref, cfg.branches)) return name
  }
  return null
}

/**
 * Derive the release version from a git ref. Tag → use the tag verbatim.
 * Branch → "<branch>-<shortSha>" so successive pushes to the same branch
 * publish distinct versions.
 */
export function deriveVersion ({ ref, sha, isTag = false }) {
  if (isTag) return ref
  if (!sha) throw new Error('deriveVersion requires sha for branch builds')
  return ref + '-' + String(sha).slice(0, 7)
}

/**
 * Run the configured steps sequentially in the given checkout dir.
 * Aborts on first failure; subsequent steps aren't attempted.
 *
 * Returns { ok, steps: [{name, status, duration, output, error?}], totalMs }.
 * Does not throw on step failure — the caller checks `ok`.
 */
export async function runSteps (checkoutDir, config, { repoName, channel, version, env: extraEnv } = {}) {
  const results = []
  const started = Date.now()

  const env = {
    ...process.env,
    CI: 'true',
    PEAR_GIT: 'true',
    REPO_NAME: repoName || '',
    CHANNEL: channel || '',
    VERSION: version || '',
    ...(extraEnv || {})
  }

  for (const step of config.steps) {
    const timeoutMs = (step.timeout || 120) * 1000
    const stepStart = Date.now()

    let output = ''
    let error = null
    try {
      output = await execStep(checkoutDir, step.run, { env, timeoutMs })
    } catch (err) {
      error = err.message || String(err)
    }

    results.push({
      name: step.name,
      status: error ? 'failed' : 'ok',
      duration: Date.now() - stepStart,
      output: output.slice(0, MAX_OUTPUT_BYTES),
      ...(error ? { error } : {})
    })

    if (error) break  // abort-on-first-failure
  }

  return {
    ok: results.length > 0 && results[results.length - 1].status === 'ok' && results.length === config.steps.length,
    steps: results,
    totalMs: Date.now() - started
  }
}

/**
 * Collect artifacts named by `config.artifacts` from the checkout dir.
 * Returns [{ path (relative), data (Buffer) }]. Missing artifacts throw
 * so the caller can fail the build cleanly rather than publishing a
 * half-empty release.
 *
 * Each entry can be either a file or a directory:
 *   - File entry: yields one manifest entry with `data = readFile(file)`.
 *   - Directory entry: walked recursively; each leaf file yields a
 *     separate manifest entry with the in-tree relative path preserved
 *     (e.g. `dist/` → `dist/index.js`, `dist/sub/x.txt`).
 *
 * Symlinks are skipped at every depth so artifacts can't escape the
 * checkout via a malicious link in the source tree.
 *
 * Files are read serially to keep peak memory bounded by the largest
 * single file (not the cumulative tree size). Callers (release-manager)
 * also process them serially, so a multi-GB tree never lands fully in
 * memory at once.
 */
export async function collectArtifacts (checkoutDir, config) {
  if (!config.artifacts || config.artifacts.length === 0) return []
  const out = []
  const root = path.resolve(checkoutDir)
  for (const rel of config.artifacts) {
    const abs = path.join(checkoutDir, rel)
    const resolved = path.resolve(abs)
    if (!resolved.startsWith(root + path.sep) && resolved !== root) {
      throw new Error('artifact escapes checkout: ' + rel)
    }
    let st
    try {
      st = await fs.promises.lstat(resolved)
    } catch (err) {
      if (err.code === 'ENOENT') throw new Error(`artifact not found after build: ${rel}`)
      throw err
    }
    if (st.isSymbolicLink()) {
      throw new Error('artifact must not be a symlink: ' + rel)
    }
    if (st.isFile()) {
      const data = await fs.promises.readFile(resolved)
      out.push({ path: rel, data })
      continue
    }
    if (st.isDirectory()) {
      await walkArtifactDir(resolved, rel, out)
      continue
    }
    throw new Error('artifact must be a regular file or directory: ' + rel)
  }
  return out
}

/**
 * Recursively walk an artifact directory, pushing each leaf file as
 * `{ path: <relPrefix>/<subpath>, data }` onto `out`. Symlinks at any
 * depth are skipped silently — the per-artifact symlink check at the
 * top level errors loudly, but a tree-walk that errors on any link
 * deep in node_modules would be too brittle for real-world builds.
 *
 * Sorted-first traversal so manifests are deterministic across runs.
 */
async function walkArtifactDir (absDir, relPrefix, out) {
  const entries = await fs.promises.readdir(absDir, { withFileTypes: true })
  entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
  for (const e of entries) {
    if (e.isSymbolicLink()) continue
    const abs = path.join(absDir, e.name)
    const rel = relPrefix + '/' + e.name
    if (e.isDirectory()) {
      await walkArtifactDir(abs, rel, out)
    } else if (e.isFile()) {
      const data = await fs.promises.readFile(abs)
      out.push({ path: rel, data })
    }
    // Sockets, FIFOs, devices: silently skipped
  }
}

// ── Internal helpers ─────────────────────────────────────────

function execStep (cwd, command, { env, timeoutMs }) {
  return new Promise((resolve, reject) => {
    const proc = spawn('sh', ['-c', command], {
      cwd,
      env,
      stdio: ['ignore', 'pipe', 'pipe']
    })

    const chunks = []
    const errChunks = []
    let size = 0

    const timer = setTimeout(() => {
      proc.kill('SIGTERM')
      reject(new Error(`timed out after ${(timeoutMs / 1000).toFixed(0)}s`))
    }, timeoutMs)

    proc.stdout.on('data', c => { size += c.length; if (size <= MAX_OUTPUT_BYTES) chunks.push(c) })
    proc.stderr.on('data', c => { size += c.length; if (size <= MAX_OUTPUT_BYTES) errChunks.push(c) })
    proc.on('error', err => { clearTimeout(timer); reject(err) })
    proc.on('exit', (code) => {
      clearTimeout(timer)
      if (code === 0) {
        resolve(Buffer.concat(chunks).toString('utf-8'))
      } else {
        const stderr = Buffer.concat(errChunks).toString('utf-8')
        reject(new Error(stderr || `exit code ${code}`))
      }
    })
  })
}

/**
 * glob-lite match for branch/tag patterns. Supports `*` wildcard, so
 * `v*` matches `v1.0`, `v2.0-rc1`, etc. Not a full glob — we don't need
 * brace expansion, character classes, or `**`.
 */
function matchesAny (value, patterns) {
  for (const p of patterns) {
    if (typeof p !== 'string') continue
    if (p === value) return true
    if (!p.includes('*')) continue
    // Escape regex metas except * (which we want to translate to .*)
    const re = new RegExp('^' + p.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$')
    if (re.test(value)) return true
  }
  return false
}