expand.js (7814B)
1 // src/lib/pipeline/expand.js - matrix and architecture expansion 2 // 3 // Turns each task template into one concrete task per combination of its 4 // dimensions, then resolves needs between the concrete tasks. 5 // 6 // Dimension matching is the part worth understanding. When a task declares 7 // needs on a template that shares a dimension with it, the dependency is 8 // matched on that dimension rather than fanned out across all of it. So an 9 // aarch64 package task needs the aarch64 build, not every build. Dimensions 10 // the dependency has but the dependent does not are fanned out, which is 11 // what you want when a single publish task waits for every architecture. 12 // Write `{ task: name, match: all }` to opt out. 13 14 import { Problems, MATRIX_KEY_PATTERN } from './schema.js'; 15 16 // Matches ${{ arch }} and ${{ matrix.pkg }}, tolerating inner whitespace. 17 const INTERPOLATION = /\$\{\{\s*([^}]*?)\s*\}\}/g; 18 19 // tasks.name is 191 characters in the mysql schema. 20 export const MAX_TASK_NAME = 191; 21 22 export function interpolate(problems, path, text, context) { 23 if (typeof text !== 'string') return text; 24 25 return text.replace(INTERPOLATION, (whole, expr) => { 26 if (expr === 'arch') { 27 if (context.arch === null) { 28 problems.add(path, 'refers to ${{ arch }} but the task declares no arch'); 29 return whole; 30 } 31 return context.arch; 32 } 33 34 const matrixMatch = /^matrix\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(expr); 35 if (matrixMatch) { 36 const key = matrixMatch[1]; 37 if (!Object.hasOwn(context.matrix, key)) { 38 const known = Object.keys(context.matrix); 39 problems.add( 40 path, 41 `refers to \${{ matrix.${key} }} which the task does not define` + 42 (known.length > 0 ? `; available: ${known.join(', ')}` : '') 43 ); 44 return whole; 45 } 46 return context.matrix[key]; 47 } 48 49 problems.add(path, `unknown expression \${{ ${expr} }}; expected arch or matrix.<name>`); 50 return whole; 51 }); 52 } 53 54 function cartesian(dimensions) { 55 let rows = [{}]; 56 for (const [key, values] of dimensions) { 57 const next = []; 58 for (const row of rows) { 59 for (const value of values) next.push({ ...row, [key]: value }); 60 } 61 rows = next; 62 } 63 return rows; 64 } 65 66 // build:arch=x86_64,pkg=musl 67 function instanceName(baseName, dims) { 68 const entries = Object.entries(dims); 69 if (entries.length === 0) return baseName; 70 return `${baseName}:${entries.map(([k, v]) => `${k}=${v}`).join(',')}`; 71 } 72 73 function dimensionsOf(task) { 74 const dims = []; 75 if (task.arch.length > 0) dims.push(['arch', task.arch]); 76 for (const [key, values] of Object.entries(task.matrix)) dims.push([key, values]); 77 return dims; 78 } 79 80 export function expandPipeline(pipeline, options = {}) { 81 const problems = new Problems(); 82 const defaultTimeout = options.defaultTimeout ?? 3600; 83 const defaultAttempts = options.defaultAttempts ?? 1; 84 85 // Pass one: build every concrete task. 86 const byBase = new Map(); 87 const instances = []; 88 89 for (const [baseName, task] of Object.entries(pipeline.tasks)) { 90 const dims = dimensionsOf(task); 91 const combos = cartesian(dims); 92 const made = []; 93 94 for (const combo of combos) { 95 const arch = combo.arch ?? null; 96 const matrix = { ...combo }; 97 delete matrix.arch; 98 99 const name = instanceName(baseName, combo); 100 const path = `tasks.${baseName}`; 101 if (name.length > MAX_TASK_NAME) { 102 problems.add(path, `expands to a task name longer than ${MAX_TASK_NAME} characters: ${name}`); 103 continue; 104 } 105 106 const context = { arch, matrix }; 107 const env = {}; 108 for (const [key, value] of Object.entries(task.env)) { 109 env[key] = interpolate(problems, `${path}.env.${key}`, value, context); 110 } 111 // Dimension values are exposed to the script, so a matrix task rarely 112 // needs interpolation at all. 113 if (arch !== null) env.ARCH = arch; 114 for (const [key, value] of Object.entries(matrix)) { 115 if (MATRIX_KEY_PATTERN.test(key)) env[`MATRIX_${key.toUpperCase()}`] = value; 116 } 117 118 made.push({ 119 name, 120 baseName, 121 arch, 122 matrix, 123 dims: combo, 124 image: interpolate(problems, `${path}.image`, task.image, context), 125 script: task.script.map((line, i) => interpolate(problems, `${path}.script[${i}]`, line, context)), 126 requires: task.requires.map((r, i) => interpolate(problems, `${path}.requires[${i}]`, r, context)), 127 services: task.services.map((service, i) => ({ 128 image: interpolate(problems, `${path}.services[${i}].image`, service.image, context), 129 alias: service.alias, 130 env: Object.fromEntries(Object.entries(service.env).map(([k, v]) => [ 131 k, interpolate(problems, `${path}.services[${i}].env.${k}`, v, context), 132 ])), 133 entrypoint: service.entrypoint, 134 command: service.command, 135 })), 136 env, 137 artifacts: task.artifacts 138 ? { 139 ...task.artifacts, 140 paths: task.artifacts.paths.map((p, i) => interpolate(problems, `${path}.artifacts.paths[${i}]`, p, context)), 141 } 142 : null, 143 allow_failure: task.allow_failure, 144 timeout: task.timeout ?? defaultTimeout, 145 max_attempts: task.max_attempts ?? defaultAttempts, 146 // Every instance of a matrix task inherits the ref rule, so a 147 // restricted task stays restricted across all of its expansions. 148 only: task.only ?? null, 149 needs: [], 150 }); 151 } 152 153 byBase.set(baseName, { task, dimensionNames: dims.map(([k]) => k), instances: made }); 154 instances.push(...made); 155 } 156 157 const byName = new Map(instances.map((i) => [i.name, i])); 158 159 // Pass two: resolve needs now that every concrete task exists. 160 for (const [baseName, entry] of byBase) { 161 for (const instance of entry.instances) { 162 const resolved = new Set(); 163 164 for (const need of entry.task.needs) { 165 const path = `tasks.${baseName}.needs`; 166 167 // An exact concrete task name wins, which is the escape hatch for 168 // depending on one specific combination. 169 if (byName.has(need.task)) { 170 if (need.task === instance.name) { 171 problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`); 172 } else { 173 resolved.add(need.task); 174 } 175 continue; 176 } 177 178 const target = byBase.get(need.task); 179 if (!target) { 180 const known = [...byBase.keys()].filter((n) => n !== baseName); 181 problems.add( 182 path, 183 `refers to unknown task ${JSON.stringify(need.task)}` + 184 (known.length > 0 ? `; defined tasks are ${known.join(', ')}` : '') 185 ); 186 continue; 187 } 188 if (need.task === baseName) { 189 problems.add(path, `task ${JSON.stringify(baseName)} cannot depend on itself`); 190 continue; 191 } 192 193 let candidates = target.instances; 194 if (need.match === 'shared') { 195 const shared = target.dimensionNames.filter((d) => Object.hasOwn(instance.dims, d)); 196 if (shared.length > 0) { 197 candidates = candidates.filter((c) => shared.every((d) => c.dims[d] === instance.dims[d])); 198 if (candidates.length === 0) { 199 problems.add( 200 path, 201 `no instance of ${JSON.stringify(need.task)} matches ${instance.name} on ` + 202 `${shared.join(', ')}; add the missing value, or use { task: ${need.task}, match: all }` 203 ); 204 continue; 205 } 206 } 207 } 208 209 for (const candidate of candidates) resolved.add(candidate.name); 210 } 211 212 instance.needs = [...resolved].sort(); 213 } 214 } 215 216 problems.throwIfAny(pipeline.source); 217 return instances; 218 }