diff --git a/fjs/ci/common/module.f.mjs b/fjs/ci/common/module.f.mjs index 1c55977de..8a8be37b5 100644 --- a/fjs/ci/common/module.f.mjs +++ b/fjs/ci/common/module.f.mjs @@ -30,8 +30,14 @@ export const stepSchema = /** @type {const} */ ({ with: or(option, record(string)) }) +// `needs` is how one job waits for another: a job that consumes an artifact +// cannot start before the job that uploads it. It is optional because most jobs +// are independent, and it is named here rather than emitted past the schema — +// `parseGitHubAction` reads back the workflow this repository generates, so an +// unmodelled key would fail that round-trip in `fjs/ci/proof.f.mjs`. export const jobSchema = /** @type {const} */ ({ 'runs-on': string, + needs: or(option, array(string)), steps: array(stepSchema) }) diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index e63836aa3..b0c869039 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -1,6 +1,7 @@ /** * @import { MetaStep, Os, GitHubAction } from './common/types.ts' * @import { Dir, State } from '../effects/node/virtual/types.ts' + * @import { Unknown } from '../djs/types.ts' */ import { exitCode } from '../effects/node/module.f.mjs' @@ -221,4 +222,39 @@ export const proof = { assert(job['runs-on'] !== undefined, 'expected runs-on') assert(job.steps.length > 0, 'expected steps') }, + jobNeeds: () => { + const steps = /** @type {const} */ ([{ run: 'echo hi' }]) + /** @type {(jobs: Unknown) => Unknown} */ + const action = jobs => ({ + name: 'test', + on: {}, + permissions: { contents: 'read' }, + jobs, + }) + // Modelled, so a job that waits for another survives the round-trip. + // Without this a consuming job could only reach the workflow by being + // emitted past the schema, which `parseGitHubAction` would then reject. + const ordered = unwrap(parseGitHubAction(action({ + pack: { 'runs-on': 'ubuntu-latest', steps }, + check: { 'runs-on': 'ubuntu-latest', needs: ['pack'], steps }, + }))) + assertEq(ordered.jobs.check?.needs?.[0], 'pack') + assertEq(ordered.jobs.check?.needs?.length, 1) + // Optional: the independent jobs, which is all of them today, still parse. + assertEq(unwrap(parseGitHubAction(action({ + pack: { 'runs-on': 'ubuntu-latest', steps }, + }))).jobs.pack?.needs, undefined) + // Constrained, not merely accepted. GitHub also allows a bare scalar + // (`needs: pack`); this generator emits the list form only, so the + // scalar is drift rather than an alternative spelling — the same reason + // these schemas are closed. + assertEq(parseGitHubAction(action({ + check: { 'runs-on': 'ubuntu-latest', needs: 'pack', steps }, + }))[0], 'error') + // Dormant until something orders itself: the first consumer is the + // packed-artifact check in `fjs/ci/todo/f-mjs-package-support.md`. + assert( + definedValues(run(false).jobs).every(job => job.needs === undefined), + 'unexpected job ordering in the generated workflow') + }, }