/**
* 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 }
}