From cbca4511bf86c08877c0cab0d3f6e48dbd2e8c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:13:15 +0000 Subject: [PATCH 1/2] ci: the job schema can express ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job that consumes an artifact must not start before the job that uploads it. The generator could not say so: jobSchema named only runs-on and steps, and it is deliberately closed, so a `needs:` key emitted past it would fail the round-trip that parseGitHubAction performs in fjs/ci/proof.f.mjs. Without this, the packed-artifact check in fjs/ci/todo/f-mjs-package-support.md could only fail at download-artifact — a required check red for a reason unrelated to what it tests, which is the one failure mode that trains people to re-run instead of read. Adds `needs: or(option, array(string))`, which widens Job through Ts. The generated ci.yml is unchanged: nothing orders itself yet, so the field stays absent. The proof covers what the field is for rather than that it exists: an ordered pair round-trips with its dependency intact; independent jobs still parse with it absent; a bare scalar `needs: pack` is rejected, since GitHub accepts that spelling but this generator emits the list form only, so a scalar is drift; and no generated job carries the field today, so the first one to do so is a deliberate change. Prerequisite for both fjs/ci/todo/ci-integration-tests.md's two-stage split and the Stage 2 packed-artifact check, which is why it is owned there rather than by either consumer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/common/module.f.mjs | 6 ++++++ fjs/ci/proof.f.mjs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) 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..7b77e74b0 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 = [{ 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') + }, } From f9cab0e80e76ff7d6bb925505570628a323041ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 19:39:21 +0000 Subject: [PATCH 2/2] ci: pin the proof's steps literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fjs/AGENTS.md requires a const with a literal initializer to pin its type; the fixture array relied on tsc's default widening, which drops readonly and the literal types — exactly what a schema-oriented proof depends on. The other two consts added here are exempt by the same rule: an arrow function and a call are not literal initializers and already carry non-widening types. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqeS5t2ZKkSu3chRPMXR7n --- fjs/ci/proof.f.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fjs/ci/proof.f.mjs b/fjs/ci/proof.f.mjs index 7b77e74b0..b0c869039 100644 --- a/fjs/ci/proof.f.mjs +++ b/fjs/ci/proof.f.mjs @@ -223,7 +223,7 @@ export const proof = { assert(job.steps.length > 0, 'expected steps') }, jobNeeds: () => { - const steps = [{ run: 'echo hi' }] + const steps = /** @type {const} */ ([{ run: 'echo hi' }]) /** @type {(jobs: Unknown) => Unknown} */ const action = jobs => ({ name: 'test',