conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }