directives.js (4778B)
1 // src/worker/directives.js - magic lines a script writes to talk back 2 // 3 // ::set-output NAME=VALUE[::masked|public] 4 // ::set-state success|failed|skipped 5 // 6 // Output arrives as arbitrary chunks and stdout interleaves with stderr, so 7 // scanning is per stream. A partial line is held back only while it could 8 // still become a directive, otherwise ordinary output would stall waiting 9 // for a newline that may never come. 10 11 export const STATES = ['success', 'failed', 'skipped']; 12 export const VISIBILITIES = ['masked', 'public']; 13 14 const COMMANDS = ['set-output', 'set-state']; 15 const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*$/; 16 const MAX_VALUE = 8192; 17 const MAX_OUTPUTS = 64; 18 19 // null when the line is not a directive. A recognised command always yields 20 // a directive or an error, so a malformed set-output is still stripped 21 // rather than printing the value it failed to parse. 22 function parse(line) { 23 if (!line.startsWith('::')) return null; 24 25 const body = line.slice(2); 26 const space = body.indexOf(' '); 27 const command = space === -1 ? body : body.slice(0, space); 28 if (!COMMANDS.includes(command)) return null; 29 const rest = space === -1 ? '' : body.slice(space + 1); 30 31 // Only the last :: separates, so a value may contain one. 32 const cut = rest.lastIndexOf('::'); 33 const args = cut === -1 ? rest : rest.slice(0, cut); 34 const data = cut === -1 ? null : rest.slice(cut + 2); 35 36 if (command === 'set-state') { 37 if (data !== null) return { error: 'set-state takes no :: value' }; 38 if (!STATES.includes(args)) { 39 return { error: `unknown state ${JSON.stringify(args)}; expected ${STATES.join(', ')}` }; 40 } 41 return { state: args }; 42 } 43 44 const eq = args.indexOf('='); 45 if (eq === -1) return { error: 'set-output wants name=value' }; 46 const name = args.slice(0, eq); 47 const value = args.slice(eq + 1); 48 if (!NAME_PATTERN.test(name)) { 49 return { error: `output name ${JSON.stringify(name)} is not a valid name` }; 50 } 51 if (value.length > MAX_VALUE) { 52 return { error: `output ${name} is longer than ${MAX_VALUE} characters` }; 53 } 54 if (data !== null && !VISIBILITIES.includes(data)) { 55 return { error: `unknown visibility ${JSON.stringify(data)}; expected ${VISIBILITIES.join(', ')}` }; 56 } 57 return { output: { name, value, masked: data !== 'public' } }; 58 } 59 60 // Last one wins, for a repeated output name and for the state alike. 61 export function createDirectives({ onMask = null, onProblem = null } = {}) { 62 const outputs = new Map(); 63 let state = null; 64 65 return { 66 get state() { return state; }, 67 get outputs() { return [...outputs.values()]; }, 68 69 accept(parsed) { 70 if (parsed.error) return onProblem?.(parsed.error); 71 if (parsed.state) { state = parsed.state; return; } 72 73 const { name, value, masked } = parsed.output; 74 if (!outputs.has(name) && outputs.size >= MAX_OUTPUTS) { 75 return onProblem?.(`more than ${MAX_OUTPUTS} outputs; ${name} was dropped`); 76 } 77 outputs.set(name, { name, value, masked }); 78 if (masked) onMask?.(value, name); 79 }, 80 }; 81 } 82 83 export function createScanner(directives) { 84 let pending = Buffer.alloc(0); 85 let passing = false; 86 87 // A directive has to begin at column 0, so only a prefix of '::' waits. 88 function mayBeDirective(buf) { 89 if (buf.length >= 2) return buf[0] === 0x3a && buf[1] === 0x3a; 90 if (buf.length === 1) return buf[0] === 0x3a; 91 return true; 92 } 93 94 function keep(line) { 95 const parsed = parse(line.toString('utf8').replace(/[\r\s]+$/, '')); 96 if (parsed === null) return true; 97 directives.accept(parsed); 98 return false; 99 } 100 101 function consume(chunk, final) { 102 const out = []; 103 let buf = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk; 104 pending = Buffer.alloc(0); 105 106 while (buf.length > 0) { 107 const nl = buf.indexOf(0x0a); 108 109 if (passing) { 110 if (nl === -1) { out.push(buf); break; } 111 out.push(buf.subarray(0, nl + 1)); 112 buf = buf.subarray(nl + 1); 113 passing = false; 114 continue; 115 } 116 117 if (nl === -1) { 118 if (final) { 119 if (keep(buf)) out.push(buf); 120 } else if (mayBeDirective(buf)) { 121 pending = buf; 122 } else { 123 out.push(buf); 124 passing = true; 125 } 126 break; 127 } 128 129 if (keep(buf.subarray(0, nl))) out.push(buf.subarray(0, nl + 1)); 130 buf = buf.subarray(nl + 1); 131 } 132 133 return out.length === 0 ? Buffer.alloc(0) : Buffer.concat(out); 134 } 135 136 return { 137 push(chunk) { return consume(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), false); }, 138 end() { 139 if (pending.length === 0) return Buffer.alloc(0); 140 const rest = pending; 141 pending = Buffer.alloc(0); 142 return consume(rest, true); 143 }, 144 }; 145 }