Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,41 @@
}
]
},
"package-check": {
"runs-on": "ubuntu-26.04-arm",
"needs": [
"node26"
],
"steps": [
{
"uses": "actions/download-artifact@v8.0.1",
"with": {
"name": "package-tarball"
}
},
{
"uses": "actions/setup-node@v7.0.0",
"with": {
"node-version": "26.7.0"
}
},
{
"run": "npm init -y > /dev/null"
},
{
"run": "npm install \"packed@file:$(echo *.tgz)\""
},
{
"run": "npm install \"typescript@=7.0.2\""
},
{
"run": "echo '{\"include\":[\"node_modules/packed/**/*\",\"node_modules/packed/**/.*\",\"node_modules/packed/**/.*/**/*\"],\"exclude\":[],\"compilerOptions\":{\"module\":\"nodenext\",\"target\":\"esnext\",\"strict\":true,\"noEmit\":true,\"skipLibCheck\":false}}' > tsconfig.json"
},
{
"run": "npx tsc"
}
]
},
"nix-flakes": {
"runs-on": "ubuntu-26.04-arm",
"steps": [
Expand Down
4 changes: 4 additions & 0 deletions changelog/unreleased/1767.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- `fjs ci` now generates a `package-check` job for a project that pins
TypeScript exactly: it downloads the packed tarball uploaded by the Node job,
installs it as a dependency outside any checkout, and type-checks every
declaration the package ships with that pinned compiler.
21 changes: 18 additions & 3 deletions fjs/ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ canonical Node job under `nix/generated/`.
- `node/module.f.mjs` — Node.js job steps: platform smoke tests, canonical
per-version jobs, coverage, package checks, and the Node flake declarations.
`proof.f.mjs` — its property-based proofs.
- `package/module.f.mjs` — the `package-check` job: downloads the tarball the
Node job uploads, installs it under a fixed alias outside any checkout, and
type-checks every declaration it ships. It is the one job built without
`toSteps`, because that helper adds `actions/checkout` and the missing
checkout is the point — with the repository on the runner there would be a
`tsconfig.json` up the tree, a `node_modules` to resolve into, and sources
standing in for declarations the tarball omits, so the check would pass on
the repository rather than on the package.
`proof.f.mjs` — its property-based proofs.
- `rust/module.f.mjs` — Rust toolchain setup and `cargo` build/test steps.
- `deno/module.f.mjs` — Deno runtime steps.
- `bun/module.f.mjs` — Bun runtime steps.
Expand Down Expand Up @@ -134,9 +143,15 @@ package has been installed. Custom projects that need different runtime setup st
should use `fjs run <custom-ci-module>` and call `ci(setup)` directly instead of
modifying the built-in command.

The built-in command does not read `package.json` to customize generated steps.
The FunctionalScript package version used by generated Node, Deno, and Bun smoke
tests is pinned in `config/module.f.mjs`, not read from `package.json`.
The built-in command reads `package.json` for one thing: `devDependencies.typescript`.
An exact version there — `=7.0.2`, not `^7.0.0` — generates the `package-check`
job and is the compiler that job installs, because a job with no checkout has no
lockfile to resolve a range against. Anything else, including no entry at all,
generates no `package-check` job.

Nothing else in `package.json` reaches the generated steps. The FunctionalScript
package version used by generated Node, Deno, and Bun smoke tests is pinned in
`config/module.f.mjs`, not read from `package.json`.

## Customisation

Expand Down
2 changes: 2 additions & 0 deletions fjs/ci/config/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export const actions = /** @type {const} */({
'actions/cache': 'v6.1.0',
// https://github.com/marketplace/actions/upload-a-build-artifact
'actions/upload-artifact': 'v7.0.1',
// https://github.com/marketplace/actions/download-a-build-artifact
'actions/download-artifact': 'v8.0.1',
// https://github.com/marketplace/actions/setup-deno
'denoland/setup-deno': 'v2.0.5',
// https://github.com/marketplace/actions/setup-bun
Expand Down
66 changes: 61 additions & 5 deletions fjs/ci/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
* @import { NixJob } from './nix/types.ts'
* @import { Setup } from './types.ts'
* @import { Effect } from '../effects/types.ts'
* @import { Result } from '../types/result/types.ts'
* @import { IoChannel } from '../effects/node/types.ts'
*/

import { resultStep } from '../effects/module.f.mjs'
import { access, exitStep, writeUtf8File } from '../effects/node/module.f.mjs'
import { access, exitStep, readUtf8File, writeUtf8File } from '../effects/node/module.f.mjs'
import { step as ioStep } from '../effects/module.f.mjs'
import { functionalscript, images } from './config/module.f.mjs'
import {
Expand All @@ -25,6 +27,8 @@ import {
import { rustPlatformSteps, rustWasmSteps } from './rust/module.f.mjs'
import { nodeMainSteps, nodeNixJobs, nodeNixVersionSteps, nodeVersionJobs } from './node/module.f.mjs'
import { nixFlakes, nixInstall } from './nix/module.f.mjs'
import { parse as jsonParse } from '../media/json/module.f.mjs'
import { packageCheckJob, packageCheckJobId } from './package/module.f.mjs'
import { bunSteps } from './bun/module.f.mjs'
import { denoSteps } from './deno/module.f.mjs'

Expand All @@ -49,24 +53,76 @@ const nixJobs = nodeNixJobs
/** @type {Job} */
const nixFlakeJob = ubuntuArm([nixInstall, ...nodeNixVersionSteps])

/** @type {(rust: boolean) => Jobs} */
const canonicalJobs = rust => ({
/** @type {(rust: boolean, pin: string | undefined) => Jobs} */
const canonicalJobs = (rust, pin) => ({
...(rust ? { wasm: ubuntuArm(rustWasmSteps) } : {}),
deno: ubuntuArm(denoSteps(functionalscript)),
bun: ubuntuArm(bunSteps(functionalscript)),
...nodeVersionJobs(functionalscript),
...(pin === undefined ? {} : { [packageCheckJobId]: packageCheckJob(pin) }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse projects without a compiler pin

When an external project has no string-valued devDependencies.typescript, fjs ci still succeeds but silently emits no package-check job, despite the changelog promising that job; such a project's CI can therefore remain green without checking its packed declarations. Fresh evidence after the earlier compiler-version comment is this new conditional omission, so fail generation with an actionable error rather than quietly removing the validation.

AGENTS.md reference: AGENTS.md:L112-L118

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the omission, and I want to be clear that it is a decision rather than an unaddressed finding.

Your premise is right: silently dropping a check is worse than failing. Where I disagree is that it is silent. The generated workflow is committed to the repository, so losing the job appears as a package-check deletion in ci.yml — in the diff, in review — and node26's regeneration check makes an unregenerated workflow red. For this repository that is louder than an error at generation time, because it is visible to a reviewer rather than only to whoever ran the command.

Failing generation instead has a cost I do not want to pay: fjs ci would stop working for any project without an exact TypeScript pin, including ones that do not want this job at all. That trades a real regression for a hypothetical one.

On the changelog: fair point, it promised the job unconditionally. Since 9f6da87 the pin must also be exact, so I will make the entry say the job is generated when the project pins TypeScript exactly, rather than implying it is unconditional.

I should also flag the framing behind this and the typesVersions thread, since it applies to both: this generator exists to build our CI. fjs ci working for other projects is a side effect, not a product commitment — so "an external project's CI can remain green" is not a cost we are carrying. Findings about our repo on this PR were right and got fixed; this one is about someone else's.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed: fjs/ci/todo/package-check-unsupported-package-shapes.md,`` in f0364d3 — issue 2.

The todo is honest that this one is cheap to build — a Result from ci(setup) and an error path through fjs ci — so the open question is whether refusing is right, not whether it is hard. My reason for not refusing is that the generator's other jobs do not depend on a pin, and a project without one still wants them.

The contract it replaces is now written down where a consumer will find it, in fjs/ci/README.md: an exact devDependencies.typescript generates the job, anything else generates none. Silently producing one fewer job was the real complaint, and documenting the rule answers it without a refusal.


Generated by Claude Code

'nix-flakes': nixFlakeJob,
})

/** @type {(s: string) => boolean} */
const digits = s => s !== '' && [...s].every(c => c >= '0' && c <= '9')

/**
* `=MAJOR.MINOR.PATCH` and nothing else.
*
* Anything npm reads as a *range* — `^7.0.0`, `=7.x`, `=7.0`, `=7.0.2 || 8.x` —
* lets a later registry release change this check's verdict with no change
* here, which is the one thing running it without a checkout is meant to
* prevent. A leading `=` is not enough on its own: it can prefix a range. So
* the whole value is validated rather than its first character.
*
* A prerelease pin is rejected too. That is stricter than npm needs, and the
* cost of being wrong is the job disappearing from `ci.yml` — a visible diff in
* review — rather than a check that silently stops meaning anything.
*
* @type {(pin: string) => boolean}
*/
const exact = pin => {
if (!pin.startsWith('=')) { return false }
const parts = pin.slice(1).split('.')
return parts.length === 3 && parts.every(digits)
}

/**
* The compiler the packed-package check installs, read out of the project's own
* `package.json` rather than restated anywhere. A second copy could disagree
* with this one silently, and a check running a compiler the package does not
* pin is a green result about the wrong thing.
*
* `undefined` when there is no package.json or no pin: the check cannot be run
* deterministically then, so it is not generated at all rather than run against
* a compiler nobody chose.
*
* @type {(text: Result<string, IoChannel>) => string | undefined}
*/
const compilerPin = text => {
if (text[0] !== 'ok') { return undefined }
const json = jsonParse(text[1])
if (json[0] !== 'ok') { return undefined }
const root = json[1]
if (typeof root !== 'object' || root === null || root instanceof Array) { return undefined }
const dev = root.devDependencies
if (typeof dev !== 'object' || dev === null || dev instanceof Array) { return undefined }
const pin = dev.typescript
return typeof pin === 'string' && exact(pin) ? pin : undefined
}

/** @type {(setup: Setup) => Effect<NodeOp, 0, number>} */
export const ci = ({ nodeExtra }) => resultStep(
readUtf8File('package.json'),
Comment thread
sergey-shandar marked this conversation as resolved.
packageJson => resultStep(
access('Cargo.toml'),
result => {
const rust = result[0] === 'ok'
const pin = compilerPin(packageJson)
/** @type {Jobs} */
const jobs = {
...Object.fromEntries(os.flatMap(o => architecture.map(job(rust, nodeExtra(o))(o)))),
...canonicalJobs(rust),
...canonicalJobs(rust, pin),
}
/** @type {GitHubAction} */
const gha = {
Expand All @@ -85,6 +141,6 @@ export const ci = ({ nodeExtra }) => resultStep(
JSON.stringify(gha, null, ' '))
const flakesWritten = ioStep(workflowWritten, () => nixFlakes(nixJobs))
return exitStep(flakesWritten)
})
}))

export const main = () => ci({ nodeExtra: () => [] })
6 changes: 6 additions & 0 deletions fjs/ci/node/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export const major = v => v.split('.')[0]
/** @type {(version: string) => string} */
const jobId = version => `node${major(version)}`

/**
* The job that packs the tarball and uploads it. A consuming job names this in
* `needs` rather than repeating the id.
*/
export const packageJobId = jobId(node.default)

/** @type {(v: string) => Step} */
const installNode = v =>
uses('actions/setup-node', { 'node-version': v })
Expand Down
135 changes: 135 additions & 0 deletions fjs/ci/package/module.f.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* The packed-package check: a job that consumes the `npm pack` artifact the
* way an outside consumer would.
*
* @module
*
* @import { Job } from '../common/types.ts'
*/
Comment thread
sergey-shandar marked this conversation as resolved.
Comment thread
sergey-shandar marked this conversation as resolved.

import { images, node } from '../config/module.f.mjs'
import { uses } from '../common/module.f.mjs'
import { packageArtifact, packageJobId } from '../node/module.f.mjs'

export const packageCheckJobId = /** @type {const} */ ('package-check')

// A fixed alias, so every later command names the package literally. The
// artifact's own name would otherwise have to be derived and carried between
// steps. The narrow case an alias gives up: a package that imports itself by
// name — legal once `exports` is declared — does not resolve under a different
// directory name. Nothing here self-references; revisit if that changes.
const alias = /** @type {const} */ ('packed')

/**
* The whole check, as a file `tsc` reads for itself.
*
* `include` does the enumeration, so no shell walks the tree and no path is
* ever serialised: a space or a quote in a directory name is a JSON string
* here and a filename to `tsc`, with nothing in between to get it wrong. An
* empty match is `TS18003`, which names the pattern that found nothing —
* "checked nothing and passed" is the failure this job most needs to be
* legible about.
*
* An earlier revision walked the tree with `find`, guarded the result with
* `test -s`, and passed it through `xargs -0`. Do not go back: review found
* three defects in that mechanism, one of them silent. `find` omitted
* `.d.cts`; the paths needed escaping to survive the shell; and `xargs` fills
* a finite command buffer, so a package large enough to overflow it — about
* twenty times this one — would have been split across several `tsc`
* invocations, each a separate program, losing cross-file diagnostics and
* reporting another batch's globals as missing. One `include` has none of
* them, and root `AGENTS.md` §6 asks for the tool that parses what it checks
* rather than a pattern approximating one.
*
* `**` rather than a list of declaration extensions, for the same reason:
* a list is a thing that can be wrong, and that one already was. It does mean
* a package shipping `.ts` sources and no declarations has a nonempty root
* set, so `TS18003` would not fire — unreachable here, because root
* `package.json` `files` is an allowlist with no pattern matching a source
* file. Recorded in `../todo/package-check-unsupported-package-shapes.md`.
*
* The three patterns are one rule TypeScript and npm disagree about: npm's
* `**` walks into a dot-prefixed name and TypeScript's does not. So `files`
* publishes `.d/x.d.ts` and a lone `**` would leave it unchecked — silently,
* and even when it is the package's `types` entry point. The extra patterns
* name a dot segment explicitly, which does match: `**\/.*` for a dot-named
* file, `**\/.*\/**\/*` for anything under a dot-named directory at any
* depth. Two dot segments in a row (`.a/.b/x.d.ts`) still escape, because the
* inner `**` has to cross `.b` — see the todo. Enumerating the names instead
* would need a tool walking the tree, which root `AGENTS.md` §6 rules out.
*
* `exclude` is emptied because the default excludes `node_modules`, which is
* the only place the artifact exists. `skipLibCheck` is stated rather than
* left at its default: it is the one option whose flip would stop `tsc`
* opening these declarations at all, and the job would still pass.
*/
const tsconfig = /** @type {const} */ ({
include: [
`node_modules/${alias}/**/*`,
`node_modules/${alias}/**/.*`,
`node_modules/${alias}/**/.*/**/*`,
],
exclude: [],
compilerOptions: {
module: 'nodenext',
target: 'esnext',
strict: true,
noEmit: true,
skipLibCheck: false,
},
})

/**
* One command per step, so a failure names what failed rather than arriving as
* an opaque script.
*
* The compiler is whatever the project pins, passed through untouched. With no
* checkout there is no lockfile, so a version chosen here instead would let the
* registry — or a constant that drifted from `package.json` — decide the
* verdict.
*
* `npm`, `npx` and `tsc` are the only external tools left, and root
* `AGENTS.md` §6 is why there are no others: `tsc` is the established tool
* that parses what it checks, and `npm` is the subject — a job proving the
* package installs for a consumer cannot avoid the consumer's package manager.
*
* @type {(pin: string) => readonly string[]}
*/
const commands = pin => [
'npm init -y > /dev/null',
// `echo` is the shell's own builtin expanding its own glob; `ls` would be
// a second process to learn what the shell already knew.
//
// No guard against a second `.tgz`: the glob would expand to two names
// inside one `file:` spec and npm fails ENOENT naming both, which is
// louder than anything a count check would print.
`npm install "${alias}@file:$(echo *.tgz)"`,
`npm install "typescript@${pin}"`,
`echo '${JSON.stringify(tsconfig)}' > tsconfig.json`,
'npx tsc',
]

/**
* Downloads the packed tarball, installs it as a real dependency, and
* type-checks every declaration it ships with the compiler the package pins.
*
* Deliberately not built through `toSteps`: that helper injects
* `actions/checkout`, and the missing checkout is this job's whole point. With
* no repository on the runner there is no `tsconfig.json` up the tree to
* inherit, no `node_modules` to resolve into, and no source file that could
* stand in for a declaration the tarball omits — so the job can only see what a
* real consumer sees.
*
* @type {(pin: string) => Job}
*/
export const packageCheckJob = pin => ({
'runs-on': images.ubuntu.arm,
// Without this the two jobs race and the download fails before the check
// has run — red for a reason unrelated to what it tests.
needs: [packageJobId],
steps: [
uses('actions/download-artifact', { name: packageArtifact }),
uses('actions/setup-node', { 'node-version': node.default }),
...commands(pin).map(run => ({ run })),
],
})
Loading
Loading