OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-deploy / lib / pipeline.js

299 lines · javascript ref:
/**
 * Deploy Pipeline — git-based CI/CD
 *
 * Flow: git push → post-receive → CI steps → commit to release branch
 *
 * 1. Clone repo to temp dir
 * 2. Run .pear-git/ci.yaml steps (or npm install fallback)
 * 3. Commit built artifacts to 'release' branch
 * 4. Record deploy in registry
 * 5. Broadcast ref-update to federation peers
 */

import path from 'bare-path'
import fs from 'bare-fs'
import { spawn } from 'bare-subprocess'

export class DeployPipeline {
  constructor (opts = {}) {
    this.storagePath = opts.storagePath
    this.reposPath = opts.reposPath
    this.gitSync = opts.gitSync || null
    this.deployLog = opts.deployLog || null
    this._locks = new Map() // repoName → Promise
  }

  /**
   * Execute a deploy for a repo
   */
  async execute (repoName, opts = {}) {
    if (this._locks.has(repoName)) {
      throw new Error(`Deploy already in progress for ${repoName}`)
    }

    const promise = this._run(repoName, opts)
    this._locks.set(repoName, promise)

    try {
      return await promise
    } finally {
      this._locks.delete(repoName)
    }
  }

  async _run (repoName, opts) {
    const repoPath = path.join(this.reposPath, repoName + '.git')
    const timestamp = Date.now()
    const checkoutDir = path.join(this.storagePath, 'tmp', `deploy-${repoName}-${timestamp}`)

    console.log(`[Deploy] Starting ${repoName}`)

    try {
      // 1. Clone
      await fs.promises.mkdir(checkoutDir, { recursive: true })
      await exec('git', ['clone', '--depth', '1', repoPath, checkoutDir])

      // 2. CI steps
      const ciConfig = loadCIConfig(checkoutDir)
      if (ciConfig) {
        console.log(`[Deploy] Running ${ciConfig.steps.length} CI step(s)`)
        for (const step of ciConfig.steps) {
          console.log(`[Deploy]   ${step.name}`)
          await exec('sh', ['-c', step.run], {
            cwd: checkoutDir,
            timeout: (step.timeout || 120) * 1000,
            env: { CI: 'true', PEAR_GIT: 'true', REPO_NAME: repoName }
          })
        }
      } else {
        // Fallback: npm install
        const hasPkg = exists(path.join(checkoutDir, 'package.json'))
        if (hasPkg) {
          console.log('[Deploy] Running npm install')
          await exec('npm', ['install', '--production'], { cwd: checkoutDir })
        }
      }

      // 3. Commit to release branch
      const commitHash = await commitToReleaseBranch(repoPath, checkoutDir, repoName, timestamp)
      const duration = Date.now() - timestamp

      console.log(`[Deploy] Complete: ${repoName} ${commitHash.slice(0, 8)} in ${duration}ms`)

      // 4. Log the deploy
      if (this.deployLog) {
        await this.deployLog.append(repoName, { status: 'success', commitHash, duration })
      }

      // 5. Broadcast ref-update
      if (this.gitSync) {
        const refs = { 'refs/heads/release': commitHash }
        this.gitSync.broadcastRefUpdate(repoName, refs)
      }

      return { repoName, commitHash, duration }
    } catch (err) {
      // Log failure
      if (this.deployLog) {
        await this.deployLog.append(repoName, { status: 'failed', error: err.message, duration: Date.now() - timestamp })
      }
      throw err
    } finally {
      // Clean up temp dir
      try { await fs.promises.rm(checkoutDir, { recursive: true }) } catch {}
    }
  }
}

/**
 * Commit built checkout to release branch in the bare repo
 */
async function commitToReleaseBranch (bareRepoPath, checkoutDir, repoName, timestamp) {
  const env = {
    GIT_DIR: bareRepoPath,
    GIT_WORK_TREE: checkoutDir,
    GIT_AUTHOR_NAME: 'pear-git-deploy',
    GIT_AUTHOR_EMAIL: 'deploy@pear-git',
    GIT_COMMITTER_NAME: 'pear-git-deploy',
    GIT_COMMITTER_EMAIL: 'deploy@pear-git'
  }

  // Check if release branch exists
  let parentHash = null
  try {
    parentHash = (await exec('git', ['--git-dir', bareRepoPath, 'rev-parse', 'refs/heads/release'])).trim()
  } catch {}

  // Figure out deploy version
  let version = 1
  if (parentHash) {
    try {
      const msg = (await exec('git', ['--git-dir', bareRepoPath, 'log', '-1', '--format=%s', parentHash])).trim()
      const match = msg.match(/v(\d+)/)
      if (match) version = parseInt(match[1], 10) + 1
    } catch {}
  }

  // Add all files to index
  await exec('git', ['add', '-A'], { env })

  // Write tree
  const treeHash = (await exec('git', ['write-tree'], { env })).trim()

  // Commit
  const commitMsg = `deploy: v${version} at ${new Date(timestamp).toISOString()}`
  const commitArgs = ['commit-tree', treeHash, '-m', commitMsg]
  if (parentHash) commitArgs.push('-p', parentHash)
  const commitHash = (await exec('git', commitArgs, { env })).trim()

  // Update release ref
  await exec('git', ['--git-dir', bareRepoPath, 'update-ref', 'refs/heads/release', commitHash])

  return commitHash
}

/**
 * Load .pear-git/ci.yaml
 */
function loadCIConfig (dir) {
  const yamlPath = path.join(dir, '.pear-git', 'ci.yaml')
  try {
    const content = fs.readFileSync(yamlPath, 'utf-8')
    // Simple YAML parser for our format (steps array)
    return parseSimpleYaml(content)
  } catch {
    return null
  }
}

function parseSimpleYaml (content) {
  const steps = []
  let current = null

  for (const line of content.split('\n')) {
    const trimmed = line.trim()
    if (trimmed === 'steps:') continue
    if (trimmed.startsWith('- name:')) {
      if (current) steps.push(current)
      current = { name: trimmed.slice('- name:'.length).trim() }
    } else if (trimmed.startsWith('run:') && current) {
      current.run = trimmed.slice('run:'.length).trim()
    } else if (trimmed.startsWith('timeout:') && current) {
      current.timeout = parseInt(trimmed.slice('timeout:'.length).trim(), 10)
    }
  }
  if (current) steps.push(current)

  return steps.length > 0 ? { steps } : null
}

function exec (cmd, args, opts = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      stdio: ['ignore', 'pipe', 'pipe'],
      cwd: opts.cwd,
      env: opts.env ? { ...opts.env } : undefined
    })
    const out = []
    const err = []
    let done = false

    const timer = opts.timeout ? setTimeout(() => {
      if (done) return; done = true
      try { child.kill() } catch {}
      reject(new Error(`Timeout after ${opts.timeout}ms`))
    }, opts.timeout) : null

    child.stdout.on('data', d => out.push(d))
    child.stderr.on('data', d => err.push(d))
    child.on('exit', code => {
      if (done) return; done = true
      if (timer) clearTimeout(timer)
      if (code === 0) resolve(Buffer.concat(out).toString())
      else reject(new Error(Buffer.concat(err).toString() || `exit ${code}`))
    })
    child.on('error', e => {
      if (done) return; done = true
      if (timer) clearTimeout(timer)
      reject(e)
    })
  })
}

/**
 * OTA Updater — poll a remote for release branch changes
 *
 * Checks a git remote periodically. If release branch has new commits,
 * fetches them and exits with code 75 (sidecar restarts the process).
 */
export class OTAUpdater {
  constructor (opts = {}) {
    this.repoPath = opts.repoPath // path to local release clone
    this.remote = opts.remote // git remote URL (punch:// or file://)
    this.branch = opts.branch || 'release'
    this.interval = opts.interval || 60000 // 1 min
    this.onUpdate = opts.onUpdate || null
    this._timer = null
    this._running = false
  }

  start () {
    if (this._timer) return
    this._timer = setInterval(() => this.check(), this.interval)
    this.check() // immediate first check
  }

  stop () {
    if (this._timer) { clearInterval(this._timer); this._timer = null }
  }

  async check () {
    if (this._running) return
    this._running = true

    try {
      // Get local HEAD
      const localHead = (await exec('git', ['-C', this.repoPath, 'rev-parse', `refs/heads/${this.branch}`])).trim()

      // Fetch remote
      await exec('git', ['-C', this.repoPath, 'fetch', this.remote, this.branch], { timeout: 30000 })

      // Get remote HEAD
      const remoteHead = (await exec('git', ['-C', this.repoPath, 'rev-parse', 'FETCH_HEAD'])).trim()

      if (localHead !== remoteHead) {
        console.log(`[OTA] Update available: ${localHead.slice(0, 8)} → ${remoteHead.slice(0, 8)}`)
        await exec('git', ['-C', this.repoPath, 'reset', '--hard', 'FETCH_HEAD'])

        if (this.onUpdate) this.onUpdate(remoteHead)
        else process.exit(75) // sidecar restart code
      }
    } catch (err) {
      // Silent on poll failures — network may be down
    } finally {
      this._running = false
    }
  }
}

/**
 * Rollback — revert release branch to previous deploy
 */
export async function rollback (bareRepoPath) {
  // Get current release HEAD
  const current = (await exec('git', ['--git-dir', bareRepoPath, 'rev-parse', 'refs/heads/release'])).trim()

  // Get parent commit
  const parent = (await exec('git', ['--git-dir', bareRepoPath, 'rev-parse', 'refs/heads/release~1'])).trim()

  // Update ref
  await exec('git', ['--git-dir', bareRepoPath, 'update-ref', 'refs/heads/release', parent])

  console.log(`[Deploy] Rolled back: ${current.slice(0, 8)} → ${parent.slice(0, 8)}`)
  return { previous: current, current: parent }
}

function exists (p) {
  try { fs.statSync(p); return true } catch { return false }
}