conductor

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

pipeline.test.js (18782B)


      1 // test/pipeline.test.js - parsing, validation and expansion of .conductor.yml
      2 
      3 import test from 'node:test';
      4 import assert from 'node:assert/strict';
      5 import { compilePipeline, parsePipeline, PipelineError } from '../src/lib/pipeline/index.js';
      6 
      7 const MINIMAL = `
      8 version: 1
      9 tasks:
     10   build:
     11     image: alpine
     12     script: [make]
     13 `;
     14 
     15 function errorPaths(fn) {
     16   try {
     17     fn();
     18   } catch (e) {
     19     assert.ok(e instanceof PipelineError, `expected PipelineError, got ${e}`);
     20     return e.errors.map((x) => x.path);
     21   }
     22   throw new Error('expected the pipeline to be rejected');
     23 }
     24 
     25 function byName(pipeline) {
     26   return Object.fromEntries(pipeline.tasks.map((j) => [j.name, j]));
     27 }
     28 
     29 test('a minimal pipeline compiles', () => {
     30   const p = compilePipeline(MINIMAL);
     31   assert.equal(p.tasks.length, 1);
     32   assert.equal(p.tasks[0].name, 'build');
     33   assert.deepEqual(p.tasks[0].script, ['make']);
     34   assert.equal(p.tasks[0].depth, 0);
     35 });
     36 
     37 test('version is required and pinned', () => {
     38   assert.deepEqual(errorPaths(() => compilePipeline('tasks:\n  a:\n    image: x\n    script: [y]\n')), ['version']);
     39   assert.deepEqual(errorPaths(() => compilePipeline('version: 2\ntasks:\n  a:\n    image: x\n    script: [y]\n')), ['version']);
     40 });
     41 
     42 test('empty and malformed documents are rejected clearly', () => {
     43   assert.throws(() => compilePipeline(''), /file is empty/);
     44   assert.throws(() => compilePipeline('- a\n- b\n'), /expected a mapping at the top level/);
     45   assert.throws(() => compilePipeline('version: 1\ntasks: {\n'), /not valid YAML/);
     46 });
     47 
     48 test('image and script are required', () => {
     49   const paths = errorPaths(() => compilePipeline('version: 1\ntasks:\n  a: {}\n'));
     50   assert.deepEqual(paths.sort(), ['tasks.a.image', 'tasks.a.script']);
     51 });
     52 
     53 test('image may come from defaults', () => {
     54   const p = compilePipeline('version: 1\ndefaults:\n  image: alpine\ntasks:\n  a:\n    script: [x]\n');
     55   assert.equal(p.tasks[0].image, 'alpine');
     56 });
     57 
     58 test('unknown keys are rejected, with a suggestion when close', () => {
     59   try {
     60     compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    scripts: [z]\n');
     61     throw new Error('expected rejection');
     62   } catch (e) {
     63     assert.match(e.message, /tasks\.a\.scripts: unknown key, did you mean script/);
     64   }
     65 });
     66 
     67 test('all problems are reported at once', () => {
     68   const paths = errorPaths(() => compilePipeline(`
     69 version: 1
     70 tasks:
     71   a:
     72     script: [x]
     73   b:
     74     image: y
     75 `));
     76   assert.ok(paths.includes('tasks.a.image'));
     77   assert.ok(paths.includes('tasks.b.script'));
     78 });
     79 
     80 test('durations accept seconds, units and composites', () => {
     81   const p = compilePipeline(`
     82 version: 1
     83 tasks:
     84   a: { image: x, script: [y], timeout: 90 }
     85   b: { image: x, script: [y], timeout: 30m }
     86   c: { image: x, script: [y], timeout: 1h30m }
     87 `);
     88   const tasks = byName(p);
     89   assert.equal(tasks.a.timeout, 90);
     90   assert.equal(tasks.b.timeout, 1800);
     91   assert.equal(tasks.c.timeout, 5400);
     92 });
     93 
     94 test('durations accept day and week units', () => {
     95   const p = compilePipeline(`
     96 version: 1
     97 tasks:
     98   a:
     99     image: x
    100     script: [y]
    101     artifacts:
    102       paths: [d]
    103       expire: 30d
    104   b:
    105     image: x
    106     script: [y]
    107     artifacts:
    108       paths: [d]
    109       expire: 2w
    110 `);
    111   const tasks = byName(p);
    112   assert.equal(tasks.a.artifacts.expire, 30 * 86400);
    113   assert.equal(tasks.b.artifacts.expire, 14 * 86400);
    114 });
    115 
    116 test('a malformed duration is rejected', () => {
    117   assert.deepEqual(
    118     errorPaths(() => compilePipeline('version: 1\ntasks:\n  a: { image: x, script: [y], timeout: soon }\n')),
    119     ['tasks.a.timeout']
    120   );
    121 });
    122 
    123 test('arch expands into one task per architecture', () => {
    124   const p = compilePipeline('version: 1\ntasks:\n  b:\n    image: x\n    script: [y]\n    arch: [x86_64, aarch64]\n');
    125   assert.deepEqual(p.tasks.map((j) => j.name).sort(), ['b:arch=aarch64', 'b:arch=x86_64']);
    126   assert.equal(byName(p)['b:arch=x86_64'].env.ARCH, 'x86_64');
    127 });
    128 
    129 test('a matrix expands to the cartesian product', () => {
    130   const p = compilePipeline(`
    131 version: 1
    132 tasks:
    133   p:
    134     image: x
    135     script: [y]
    136     matrix:
    137       pkg: [musl, busybox]
    138       mode: [debug, release]
    139 `);
    140   assert.equal(p.tasks.length, 4);
    141   // Dimensions keep their declaration order, so names are predictable.
    142   assert.deepEqual(p.tasks.map((j) => j.name).sort(), [
    143     'p:pkg=busybox,mode=debug',
    144     'p:pkg=busybox,mode=release',
    145     'p:pkg=musl,mode=debug',
    146     'p:pkg=musl,mode=release',
    147   ]);
    148   const one = byName(p)['p:pkg=musl,mode=debug'];
    149   assert.equal(one.env.MATRIX_PKG, 'musl');
    150   assert.equal(one.env.MATRIX_MODE, 'debug');
    151 });
    152 
    153 test('needs match on shared dimensions rather than fanning out', () => {
    154   const p = compilePipeline(`
    155 version: 1
    156 tasks:
    157   build:
    158     image: x
    159     script: [y]
    160     arch: [x86_64, aarch64]
    161   package:
    162     image: x
    163     script: [y]
    164     arch: [x86_64, aarch64]
    165     matrix:
    166       pkg: [musl]
    167     needs: [build]
    168 `);
    169   const tasks = byName(p);
    170   assert.deepEqual(tasks['package:arch=x86_64,pkg=musl'].needs, ['build:arch=x86_64']);
    171   assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].needs, ['build:arch=aarch64']);
    172 });
    173 
    174 test('a task without the shared dimension depends on every instance', () => {
    175   const p = compilePipeline(`
    176 version: 1
    177 tasks:
    178   build:
    179     image: x
    180     script: [y]
    181     arch: [x86_64, aarch64]
    182   publish:
    183     image: x
    184     script: [y]
    185     needs: [build]
    186 `);
    187   assert.deepEqual(byName(p).publish.needs, ['build:arch=aarch64', 'build:arch=x86_64']);
    188 });
    189 
    190 test('match all opts out of dimension matching', () => {
    191   const p = compilePipeline(`
    192 version: 1
    193 tasks:
    194   build:
    195     image: x
    196     script: [y]
    197     arch: [x86_64, aarch64]
    198   check:
    199     image: x
    200     script: [y]
    201     arch: [x86_64, aarch64]
    202     needs:
    203       - task: build
    204         match: all
    205 `);
    206   assert.deepEqual(byName(p)['check:arch=x86_64'].needs, ['build:arch=aarch64', 'build:arch=x86_64']);
    207 });
    208 
    209 test('needs may name one concrete instance', () => {
    210   const p = compilePipeline(`
    211 version: 1
    212 tasks:
    213   build:
    214     image: x
    215     script: [y]
    216     arch: [x86_64, aarch64]
    217   docs:
    218     image: x
    219     script: [y]
    220     needs: ['build:arch=x86_64']
    221 `);
    222   assert.deepEqual(byName(p).docs.needs, ['build:arch=x86_64']);
    223 });
    224 
    225 test('an unsatisfiable dimension match is reported, not silently dropped', () => {
    226   try {
    227     compilePipeline(`
    228 version: 1
    229 tasks:
    230   build:
    231     image: x
    232     script: [y]
    233     arch: [x86_64]
    234   package:
    235     image: x
    236     script: [y]
    237     arch: [riscv64]
    238     needs: [build]
    239 `);
    240     throw new Error('expected rejection');
    241   } catch (e) {
    242     assert.match(e.message, /no instance of "build" matches package:arch=riscv64 on arch/);
    243     assert.match(e.message, /match: all/);
    244   }
    245 });
    246 
    247 test('needs on an unknown task lists what is defined', () => {
    248   try {
    249     compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    needs: [nope]\n');
    250     throw new Error('expected rejection');
    251   } catch (e) {
    252     assert.match(e.message, /refers to unknown task "nope"/);
    253   }
    254 });
    255 
    256 test('self dependency is rejected', () => {
    257   assert.throws(
    258     () => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    needs: [a]\n'),
    259     /cannot depend on itself/
    260   );
    261 });
    262 
    263 test('a dependency cycle names the cycle', () => {
    264   assert.throws(() => compilePipeline(`
    265 version: 1
    266 tasks:
    267   a: { image: x, script: [s], needs: [c] }
    268   b: { image: x, script: [s], needs: [a] }
    269   c: { image: x, script: [s], needs: [b] }
    270 `), /dependency cycle: (a -> c -> b -> a|b -> a -> c -> b|c -> b -> a -> c)/);
    271 });
    272 
    273 test('interpolation substitutes arch and matrix values', () => {
    274   const p = compilePipeline(`
    275 version: 1
    276 tasks:
    277   b:
    278     image: 'builder:\${{ arch }}'
    279     arch: [x86_64]
    280     matrix:
    281       pkg: [musl]
    282     script: ['build \${{ matrix.pkg }} for \${{ arch }}']
    283     env:
    284       TAG: '\${{ matrix.pkg }}-\${{ arch }}'
    285 `);
    286   const task = p.tasks[0];
    287   assert.equal(task.image, 'builder:x86_64');
    288   assert.deepEqual(task.script, ['build musl for x86_64']);
    289   assert.equal(task.env.TAG, 'musl-x86_64');
    290 });
    291 
    292 test('interpolating an undefined dimension is an error', () => {
    293   try {
    294     compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: ["\${{ matrix.nope }}"]\n');
    295     throw new Error('expected rejection');
    296   } catch (e) {
    297     assert.match(e.message, /matrix\.nope \}\} which the task does not define/);
    298   }
    299 });
    300 
    301 test('referring to arch without declaring one is an error', () => {
    302   assert.throws(
    303     () => compilePipeline('version: 1\ntasks:\n  a:\n    image: "x:\${{ arch }}"\n    script: [y]\n'),
    304     /the task declares no arch/
    305   );
    306 });
    307 
    308 test('matrix values must be safe for use in a task name', () => {
    309   assert.deepEqual(
    310     errorPaths(() => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    matrix:\n      k: ["has space"]\n')),
    311     ['tasks.a.matrix.k[0]']
    312   );
    313 });
    314 
    315 test('arch may not also be a matrix dimension', () => {
    316   assert.deepEqual(
    317     errorPaths(() => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    matrix:\n      arch: [x86_64]\n')),
    318     ['tasks.a.matrix.arch']
    319   );
    320 });
    321 
    322 test('services get a derived alias and reject duplicates', () => {
    323   const p = compilePipeline(`
    324 version: 1
    325 tasks:
    326   a:
    327     image: x
    328     script: [y]
    329     services:
    330       - docker:dind
    331       - image: registry.example.com/team/postgres:16
    332         alias: db
    333 `);
    334   assert.deepEqual(p.tasks[0].services.map((s) => s.alias), ['docker', 'db']);
    335 
    336   assert.throws(() => compilePipeline(`
    337 version: 1
    338 tasks:
    339   a:
    340     image: x
    341     script: [y]
    342     services: [docker:dind, docker:24-dind]
    343 `), /duplicate service alias/);
    344 });
    345 
    346 test('artifacts accept a bare list and reject absolute paths', () => {
    347   const p = compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    artifacts: [dist/**]\n');
    348   assert.deepEqual(p.tasks[0].artifacts.paths, ['dist/**']);
    349   assert.equal(p.tasks[0].artifacts.when, 'on_success');
    350 
    351   assert.deepEqual(
    352     errorPaths(() => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    artifacts: [/etc/passwd]\n')),
    353     ['tasks.a.artifacts.paths[0]']
    354   );
    355 });
    356 
    357 test('artifact when is constrained', () => {
    358   assert.deepEqual(
    359     errorPaths(() => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    artifacts:\n      paths: [d]\n      when: maybe\n')),
    360     ['tasks.a.artifacts.when']
    361   );
    362 });
    363 
    364 test('defaults are inherited and overridden per task', () => {
    365   const p = compilePipeline(`
    366 version: 1
    367 defaults:
    368   image: base
    369   timeout: 10m
    370   env:
    371     SHARED: yes
    372   requires: [docker]
    373 tasks:
    374   a:
    375     script: [x]
    376   b:
    377     image: custom
    378     timeout: 1h
    379     script: [x]
    380     env:
    381       OWN: no
    382 `);
    383   const tasks = byName(p);
    384   assert.equal(tasks.a.image, 'base');
    385   assert.equal(tasks.a.timeout, 600);
    386   assert.deepEqual(tasks.a.requires, ['docker']);
    387   assert.equal(tasks.b.image, 'custom');
    388   assert.equal(tasks.b.timeout, 3600);
    389   assert.equal(tasks.b.env.SHARED, 'yes');
    390   assert.equal(tasks.b.env.OWN, 'no');
    391 });
    392 
    393 test('env values must be scalars with valid names', () => {
    394   const paths = errorPaths(() => compilePipeline(`
    395 version: 1
    396 tasks:
    397   a:
    398     image: x
    399     script: [y]
    400     env:
    401       'bad name': 1
    402       GOOD: { nested: true }
    403 `));
    404   assert.deepEqual(paths.sort(), ['tasks.a.env.GOOD', 'tasks.a.env.bad name']);
    405 });
    406 
    407 test('parsePipeline normalizes without expanding', () => {
    408   const doc = parsePipeline(MINIMAL);
    409   assert.equal(doc.version, 1);
    410   assert.deepEqual(Object.keys(doc.tasks), ['build']);
    411   assert.deepEqual(doc.tasks.build.needs, []);
    412 });
    413 
    414 test('a realistic distribution pipeline produces the expected graph', () => {
    415   const p = compilePipeline(`
    416 version: 1
    417 defaults:
    418   image: debian:bookworm-slim
    419 tasks:
    420   lint:
    421     script: [make lint]
    422   build:
    423     arch: [x86_64, aarch64]
    424     script: ['./mk/build.sh $ARCH']
    425   package:
    426     needs: [build]
    427     arch: [x86_64, aarch64]
    428     matrix:
    429       pkg: [musl, busybox]
    430     requires: [sign-key]
    431     script: ['./mk/pkg.sh $MATRIX_PKG $ARCH']
    432   publish:
    433     needs: [package, lint]
    434     script: [./mk/publish.sh]
    435 `);
    436 
    437   assert.equal(p.tasks.length, 1 + 2 + 4 + 1);
    438   const tasks = byName(p);
    439   assert.equal(tasks.publish.needs.length, 5);
    440   assert.equal(tasks.publish.depth, 2);
    441   assert.deepEqual(tasks['package:arch=aarch64,pkg=musl'].requires, ['sign-key']);
    442 });
    443 
    444 // --- after ---
    445 
    446 test('after resolves to task names in its own list, separate from needs', () => {
    447   const p = compilePipeline(`
    448 version: 1
    449 defaults:
    450   image: x
    451 tasks:
    452   build:
    453     script: [y]
    454   test:
    455     script: [y]
    456     needs: [build]
    457   notify:
    458     script: [y]
    459     after: [test]
    460 `);
    461   const tasks = byName(p);
    462   assert.deepEqual(tasks.notify.needs, []);
    463   assert.deepEqual(tasks.notify.after, ['test']);
    464   // It still sits downstream, so the graph reads in running order.
    465   assert.equal(tasks.notify.depth, 2);
    466 });
    467 
    468 test('after matches shared dimensions exactly as needs does', () => {
    469   const p = compilePipeline(`
    470 version: 1
    471 defaults:
    472   image: x
    473 tasks:
    474   build:
    475     script: [y]
    476     arch: [x86_64, aarch64]
    477   cleanup:
    478     script: [y]
    479     arch: [x86_64, aarch64]
    480     after: [build]
    481   report:
    482     script: [y]
    483     after: [build]
    484 `);
    485   const tasks = byName(p);
    486   assert.deepEqual(tasks['cleanup:arch=x86_64'].after, ['build:arch=x86_64']);
    487   assert.deepEqual(tasks['cleanup:arch=aarch64'].after, ['build:arch=aarch64']);
    488   // No shared dimension, so it waits for every instance.
    489   assert.deepEqual(tasks.report.after, ['build:arch=aarch64', 'build:arch=x86_64']);
    490 });
    491 
    492 test('after accepts the mapping form too', () => {
    493   const p = compilePipeline(`
    494 version: 1
    495 defaults:
    496   image: x
    497 tasks:
    498   build:
    499     script: [y]
    500     arch: [x86_64, aarch64]
    501   cleanup:
    502     script: [y]
    503     arch: [x86_64, aarch64]
    504     after:
    505       - task: build
    506         match: all
    507 `);
    508   assert.deepEqual(
    509     byName(p)['cleanup:arch=x86_64'].after,
    510     ['build:arch=aarch64', 'build:arch=x86_64']
    511   );
    512 });
    513 
    514 test('a dependency named both ways keeps only the stricter one', () => {
    515   // needs already implies after, so recording both would be a contradiction
    516   // waiting to be read the wrong way round.
    517   const p = compilePipeline(`
    518 version: 1
    519 defaults:
    520   image: x
    521 tasks:
    522   build:
    523     script: [y]
    524   deploy:
    525     script: [y]
    526     needs: [build]
    527     after: [build]
    528 `);
    529   const deploy = byName(p).deploy;
    530   assert.deepEqual(deploy.needs, ['build']);
    531   assert.deepEqual(deploy.after, []);
    532 });
    533 
    534 test('after is validated like needs', () => {
    535   assert.throws(
    536     () => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    after: [a]\n'),
    537     /cannot depend on itself/
    538   );
    539   assert.throws(
    540     () => compilePipeline('version: 1\ntasks:\n  a:\n    image: x\n    script: [y]\n    after: [ghost]\n'),
    541     /tasks\.a\.after[\s\S]*unknown task "ghost"/
    542   );
    543   assert.throws(
    544     () => compilePipeline(`
    545 version: 1
    546 tasks:
    547   a: { image: x, script: [s], after: [b] }
    548   b: { image: x, script: [s], after: [a] }
    549 `), /dependency cycle/);
    550 });
    551 
    552 // --- task outputs ---
    553 
    554 test('reading an output implies needing the task, and is left for dispatch', () => {
    555   const p = compilePipeline(`
    556 version: 1
    557 defaults:
    558   image: x
    559 tasks:
    560   build:
    561     script: [make]
    562   deploy:
    563     script: ['ship \${{ tasks.build.outputs.tag }}']
    564 `);
    565   const deploy = byName(p).deploy;
    566   assert.deepEqual(deploy.needs, ['build'], 'the reference is a reason to wait');
    567   assert.deepEqual(deploy.output_refs, { build: ['build'] });
    568   assert.deepEqual(
    569     deploy.script,
    570     ['ship ${{ tasks.build.outputs.tag }}'],
    571     'the value is not known yet, so the reference survives compilation'
    572   );
    573   assert.equal(deploy.depth, 1);
    574 });
    575 
    576 test('an implied need is not repeated when it is also declared', () => {
    577   const p = compilePipeline(`
    578 version: 1
    579 defaults:
    580   image: x
    581 tasks:
    582   build:
    583     script: [make]
    584   deploy:
    585     script: ['ship \${{ tasks.build.outputs.tag }}']
    586     needs: [build]
    587 `);
    588   assert.deepEqual(byName(p).deploy.needs, ['build']);
    589 });
    590 
    591 test('reading an output of a matrix task waits for every instance of it', () => {
    592   const p = compilePipeline(`
    593 version: 1
    594 defaults:
    595   image: x
    596 tasks:
    597   build:
    598     arch: [x86_64, aarch64]
    599     script: ['echo "::set-output pkg-\${{ arch }}=/out/\${{ arch }}.tar::public"']
    600   index:
    601     script: ['index \${{ tasks.build.outputs.pkg-x86_64 }} \${{ tasks.build.outputs.pkg-aarch64 }}']
    602 `);
    603   const index = byName(p).index;
    604   assert.deepEqual(index.needs, ['build:arch=aarch64', 'build:arch=x86_64']);
    605   assert.deepEqual(index.output_refs, { build: ['build:arch=aarch64', 'build:arch=x86_64'] });
    606 });
    607 
    608 test('a shared dimension still narrows an output reference to its own instance', () => {
    609   const p = compilePipeline(`
    610 version: 1
    611 defaults:
    612   image: x
    613 tasks:
    614   build:
    615     arch: [x86_64, aarch64]
    616     script: [make]
    617   sign:
    618     arch: [x86_64, aarch64]
    619     script: ['sign \${{ tasks.build.outputs.path }}']
    620 `);
    621   const tasks = byName(p);
    622   assert.deepEqual(tasks['sign:arch=x86_64'].output_refs, { build: ['build:arch=x86_64'] });
    623   assert.deepEqual(tasks['sign:arch=aarch64'].needs, ['build:arch=aarch64']);
    624 });
    625 
    626 test('an output name may carry dots and dashes', () => {
    627   const p = compilePipeline(`
    628 version: 1
    629 defaults:
    630   image: x
    631 tasks:
    632   cook:
    633     script: [make]
    634   eat:
    635     script: ['eat \${{ tasks.cook.outputs.pizza.calzone }} \${{ tasks.cook.outputs.side-dish }}']
    636 `);
    637   assert.deepEqual(byName(p).eat.needs, ['cook']);
    638 });
    639 
    640 test('outputs cannot be read where a worker is chosen', () => {
    641   assert.throws(
    642     () => compilePipeline(`
    643 version: 1
    644 defaults:
    645   image: x
    646 tasks:
    647   build:
    648     script: [make]
    649   deploy:
    650     script: [ship]
    651     requires: ['\${{ tasks.build.outputs.feature }}']
    652 `),
    653     /tasks\.deploy\.requires\[0\][\s\S]*not known until the task is dispatched/
    654   );
    655 });
    656 
    657 test('an output reference is validated like any other dependency', () => {
    658   assert.throws(
    659     () => compilePipeline(`
    660 version: 1
    661 tasks:
    662   a: { image: x, script: ['\${{ tasks.ghost.outputs.v }}'] }
    663 `),
    664     /unknown task "ghost"/
    665   );
    666   assert.throws(
    667     () => compilePipeline(`
    668 version: 1
    669 tasks:
    670   a: { image: x, script: ['\${{ tasks.a.outputs.v }}'] }
    671 `),
    672     /cannot depend on itself/
    673   );
    674 });
    675 
    676 test('an output reference can close a cycle', () => {
    677   assert.throws(
    678     () => compilePipeline(`
    679 version: 1
    680 defaults:
    681   image: x
    682 tasks:
    683   a:
    684     script: ['\${{ tasks.b.outputs.v }}']
    685   b:
    686     script: ['\${{ tasks.a.outputs.v }}']
    687 `),
    688     /dependency cycle/
    689   );
    690 });
    691 
    692 test('an unknown expression names task outputs as an option', () => {
    693   assert.throws(
    694     () => compilePipeline('version: 1\ntasks:\n  a: { image: x, script: ["${{ nonsense }}"] }\n'),
    695     /expected arch, matrix\.<name> or tasks\.<task>\.outputs\.<name>/
    696   );
    697 });
    698 
    699 test('the pipeline cannot set visibility; it is a project setting', () => {
    700   for (const value of ['public', 'private']) {
    701     assert.deepEqual(
    702       errorPaths(() => compilePipeline(`version: 1\nvisibility: ${value}\n${MINIMAL.slice(MINIMAL.indexOf('tasks:'))}`)),
    703       ['visibility']
    704     );
    705   }
    706 });