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