From 62cdae292f0047a722599341b35e8a37d931c913 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 9 Nov 2026 23:19:28 -0500 Subject: [PATCH 1/6] ci: add opt-in Bun and Deno jobs, and run ci.yml on every system against a fixture ci.yml gains two jobs, bun and deno, each behind its own input (run-bun, run-deno) and off by default, that install with pnpm from the lockfile and run the caller's own test script under that runtime. A working-directory input, "." by default, lets this repository run the workflow against a package under test/. self-check.yml gains a systems job that calls ci.yml on ubuntu-latest, windows-latest and macos-latest, with both runtimes on, against test/fixtures/ci-caller: a package whose scripts each fail on the hazard they stand for, and which runs the real dash and PHI checkers from @cosyte/script-utils against clean and planted input. Claude-Session: https://claude.ai/code/session_016orFiR8mq7oCYo77QDTw2U --- .github/workflows/ci.yml | 150 +++++++++ .github/workflows/self-check.yml | 21 ++ test/ci-caller-fixture.test.mjs | 245 ++++++++++++++ test/ci-docs-content.test.mjs | 12 +- test/ci-systems.test.mjs | 304 ++++++++++++++++++ test/fixtures/ci-caller/.gitignore | 3 + .../ci-caller/docs-content/guides/usage.md | 3 + test/fixtures/ci-caller/docs-content/intro.md | 3 + .../ci-caller/docs-content/sidebars.json | 3 + test/fixtures/ci-caller/package.json | 28 ++ test/fixtures/ci-caller/pnpm-lock.yaml | 24 ++ .../ci-caller/scripts/phi-allow-list.txt | 1 + test/fixtures/ci-caller/src/index.d.ts | 1 + test/fixtures/ci-caller/src/index.mjs | 1 + test/fixtures/ci-caller/tool.mjs | 173 ++++++++++ test/skipped-required-context.test.mjs | 7 +- test/workflow-sha.test.mjs | 3 +- 17 files changed, 977 insertions(+), 5 deletions(-) create mode 100644 test/ci-caller-fixture.test.mjs create mode 100644 test/ci-systems.test.mjs create mode 100644 test/fixtures/ci-caller/.gitignore create mode 100644 test/fixtures/ci-caller/docs-content/guides/usage.md create mode 100644 test/fixtures/ci-caller/docs-content/intro.md create mode 100644 test/fixtures/ci-caller/docs-content/sidebars.json create mode 100644 test/fixtures/ci-caller/package.json create mode 100644 test/fixtures/ci-caller/pnpm-lock.yaml create mode 100644 test/fixtures/ci-caller/scripts/phi-allow-list.txt create mode 100644 test/fixtures/ci-caller/src/index.d.ts create mode 100644 test/fixtures/ci-caller/src/index.mjs create mode 100644 test/fixtures/ci-caller/tool.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 502ba2a..043bdaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,11 @@ on: description: "JSON array of Node versions for the matrix." type: string default: '["22", "24", "26"]' + # `verify` runs on each entry. Every shared step in it is proven on `ubuntu-latest`, + # `windows-latest` and `macos-latest` by `self-check.yml`'s `systems` job, which runs this + # workflow on all three against `test/fixtures/ci-caller/`; README.md, "Every system and + # runtime", says what that run proves and what it cannot. A caller widens this with + # `os: '["ubuntu-latest", "windows-latest", "macos-latest"]'`. os: description: "JSON array of runner OSes for the matrix." type: string @@ -123,6 +128,40 @@ on: description: "Resolve docs-content/ links, images and sidebar ids (needs docs-content/sidebars.json)." type: boolean default: false + # THE DIRECTORY HOLDING THE PACKAGE, for `verify`, `bun` and `deno`. It exists so + # `self-check.yml` can run this workflow against the fixture under `test/`, since this + # repository is not a package itself. `prepublish` and `actionlint` do not read it: they read + # the repository root, so a caller that moves it off "." turns `prepublish` off too. At "." every + # step runs exactly as it did before the input existed. + working-directory: + description: "The directory holding package.json, relative to the repository root. verify, bun and deno only." + type: string + default: "." + # THE TEST SUITE UNDER BUN, in its own job, `bun`, on ubuntu-latest. OFF BY DEFAULT, so no caller + # changes until it opts in by name. The job installs with pnpm from the committed lockfile, as + # `verify` does, then runs `bun --bun run test`: the caller's own `test` script, with every + # `node` it starts (a bin's `#!/usr/bin/env node`, a literal `node`) run by Bun instead. + run-bun: + description: "Run the test script under Bun, in the bun job." + type: boolean + default: false + bun-version: + description: "The Bun version the bun job installs." + type: string + default: "1.4.4" + # THE TEST SUITE UNDER DENO, in its own job, `deno`, on ubuntu-latest. OFF BY DEFAULT, for the + # same reason. The job installs with pnpm, then runs `deno task test` with no `node` on PATH: + # Deno's task runner runs a script's literal `node` with the real Node whenever one is on PATH, + # and with Deno only when none is, so the step takes Node off PATH and refuses to run the suite + # if a `node` is still reachable. + run-deno: + description: "Run the test script under Deno, in the deno job." + type: boolean + default: false + deno-version: + description: "The Deno version the deno job installs." + type: string + default: "2.9.8" permissions: contents: read @@ -138,13 +177,21 @@ jobs: # A caller cannot bound a job that calls this workflow, so the bound is here: about seven times # the slowest leg measured on 2026-11-07 (4 minutes, hl7 on Node 24). timeout-minutes: 30 + defaults: + run: + working-directory: ${{ inputs.working-directory }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # At the default "." these two read exactly what they read before `working-directory` existed: + # `package.json` is the action's own default, and an empty `cache-dependency-path` is unset. - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: ${{ inputs.working-directory == '.' && 'package.json' || format('{0}/package.json', inputs.working-directory) }} - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node }} cache: pnpm + cache-dependency-path: ${{ inputs.working-directory != '.' && format('{0}/pnpm-lock.yaml', inputs.working-directory) || '' }} # THE DOCS-CONTENT GATE RUNS HERE, IN `verify`, AS TWO STEPS, AND EVERY PART OF THAT IS # DELIBERATE. # @@ -411,3 +458,106 @@ jobs: mapfile -t files <<< "${listed}" "${RUNNER_TEMP}/actionlint" -color "${files[@]}" echo "actionlint clean over ${#files[@]} workflow file(s)." + + # THE TEST SUITE UNDER BUN AND UNDER DENO, each in its own job, `bun` and `deno`, each OFF BY + # DEFAULT behind its own input, so a caller that passes nothing gets two skipped jobs and no other + # change. Each installs exactly what `verify` installs, with pnpm from the committed lockfile, and + # then runs the caller's own `test` script under the other runtime. Both run on ubuntu-latest only: + # the system matrix is `verify`'s, and one system per runtime keeps each job id a single, stable + # check context, ` / bun` and ` / deno`. + # + # A SKIPPED JOB SATISFIES A REQUIRED CONTEXT, so a caller that requires `ci / bun` and later turns + # `run-bun` off keeps a green merge button over a context that ran nothing (README.md, "What a + # skipped required context does to a merge"). Require these only while the input is on. + bun: + if: ${{ inputs.run-bun }} + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: ${{ inputs.working-directory == '.' && 'package.json' || format('{0}/package.json', inputs.working-directory) }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ fromJSON(inputs.node-versions)[0] }} + cache: pnpm + cache-dependency-path: ${{ inputs.working-directory != '.' && format('{0}/pnpm-lock.yaml', inputs.working-directory) || '' }} + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ inputs.bun-version }} + - run: pnpm install --frozen-lockfile + # `--bun` is what puts the suite on Bun: without it, `bun run` starts a script's + # `#!/usr/bin/env node` bins (vitest, tsx, a `cosyte-process` verb) with Node. Measured on + # 2026-11-10 with a test that throws `process.versions`: with `--bun` the vitest workers report + # Bun, and without it they report Node. + - name: The test suite under Bun + shell: bash + run: | + set -euo pipefail + echo "bun: $(bun --version)" + bun --bun run test + + deno: + if: ${{ inputs.run-deno }} + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: ${{ inputs.working-directory == '.' && 'package.json' || format('{0}/package.json', inputs.working-directory) }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ fromJSON(inputs.node-versions)[0] }} + cache: pnpm + cache-dependency-path: ${{ inputs.working-directory != '.' && format('{0}/pnpm-lock.yaml', inputs.working-directory) || '' }} + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: ${{ inputs.deno-version }} + - run: pnpm install --frozen-lockfile + # NODE COMES OFF PATH FIRST. `deno task` runs a package bin with Deno, but runs a script's + # literal `node` with the real Node whenever one is on PATH, and with Deno only when none is: + # measured on 2026-11-10, `node tool.mjs test` reported Node with Node on PATH and Deno without + # it. So every PATH entry holding a `node` is replaced by a directory of links to everything + # else in it, and the step refuses to run the suite while any `node` is still reachable. + - name: The test suite under Deno + shell: bash + run: | + set -euo pipefail + shadow="${RUNNER_TEMP}/path-without-node" + rm -rf "${shadow}" + kept=() + count=0 + IFS=':' read -r -a entries <<< "${PATH}" + for entry in "${entries[@]}"; do + if [ -n "${entry}" ] && [ -e "${entry}/node" ]; then + count=$((count + 1)) + mirror="${shadow}/${count}" + mkdir -p "${mirror}" + for tool in "${entry}"/*; do + if [ "${tool##*/}" != node ]; then ln -s "${tool}" "${mirror}/"; fi + done + echo "deno: ${entry} holds a node, so PATH names ${mirror} in its place, with everything else in it" + entry="${mirror}" + fi + kept+=("${entry}") + done + PATH="$(IFS=':'; echo "${kept[*]}")" + export PATH + if found="$(command -v node)"; then + echo "::error::a node is still on PATH at ${found}, so the suite would run under Node rather than Deno" + exit 1 + fi + echo "deno: $(deno --version | head -n 1)" + deno task test diff --git a/.github/workflows/self-check.yml b/.github/workflows/self-check.yml index ddfc368..29d4986 100644 --- a/.github/workflows/self-check.yml +++ b/.github/workflows/self-check.yml @@ -127,3 +127,24 @@ jobs: node-version: "22" - name: The package lists follow the catalog run: node test/package-lists.mjs + + # `ci.yml` ITSELF, on every system and runtime, against `test/fixtures/ci-caller/`: a package whose + # scripts each fail on the hazard they stand for (a carriage return from a Windows checkout, a + # dash or PHI checker that cannot run or finds nothing, a run on the wrong system, a missing + # export target). `verify` runs on Linux, Windows and macOS at every default Node line, and `bun` + # and `deno` run the fixture's test script under Bun and Deno, so a change to a shared step is + # proven on all three systems here, before a caller adopts it. `prepublish` and `actionlint` are + # off: they read the repository root, which is not a package, and this repository's own + # `actionlint` job above already lints every workflow file. + systems: + uses: ./.github/workflows/ci.yml + with: + working-directory: test/fixtures/ci-caller + os: '["ubuntu-latest", "windows-latest", "macos-latest"]' + run-phi-scan: true + check-docs-content: true + run-bun: true + run-deno: true + run-actionlint: false + run-prepublish-manifest-lint: false + run-prepublish-install: false diff --git a/test/ci-caller-fixture.test.mjs b/test/ci-caller-fixture.test.mjs new file mode 100644 index 0000000..ed8354f --- /dev/null +++ b/test/ci-caller-fixture.test.mjs @@ -0,0 +1,245 @@ +// The CI caller fixture's own scripts (`test/fixtures/ci-caller/tool.mjs`), each shown failing on the +// hazard it stands for and passing without it. `self-check.yml`'s `systems` job runs `ci.yml` against +// the fixture on Linux, Windows and macOS and under Bun and Deno; a green run there means something +// only if each of these scripts goes red when its hazard is present, which is what this file proves. +// The two checkers the fixture runs (`cosyte-no-emdash` and `cosyte-phi-scan`) are replaced here by +// stubs, since this repository installs nothing; the `systems` run uses the real ones. + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + PLANTED, + carriageReturns, + filesUnder, + judge, + missingTargets, + platformProblem, + run, + runtimeOf, +} from './fixtures/ci-caller/tool.mjs'; + +const FIXTURE = resolve(dirname(fileURLToPath(import.meta.url)), 'fixtures/ci-caller'); +const GREETING = 'export const greeting = "hello from the CI caller fixture";\n'; +const EXPORTS = { exports: { '.': { types: './dist/index.d.ts', import: './dist/index.mjs', require: './dist/index.cjs' } } }; + +/** A throwaway package root holding `files`, a map of relative path to content. */ +function tree(files) { + const root = mkdtempSync(join(tmpdir(), 'ci-caller-')); + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + } + return root; +} + +/** Run a verb with a log that is kept, on a system the runner agrees with. */ +async function verb(name, root, overrides = {}) { + const lines = []; + const code = await run(name, { + root, + env: {}, + platform: 'linux', + versions: { node: '24.0.0' }, + log: (line) => lines.push(line), + ...overrides, + }); + return { code, lines }; +} + +/** + * A directory of stub checkers. The dash stub fails on an em dash in its stdin; the PHI stub fails on a + * dashed SSN shape in any file it is named. `brokenDash` and `brokenPhi` make each one pass everything. + */ +function stubs({ brokenDash = false, brokenPhi = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'ci-caller-bin-')); + writeFileSync( + join(dir, 'cosyte-no-emdash.js'), + brokenDash + ? 'process.exit(0);\n' + : 'let t = "";\nprocess.stdin.on("data", (c) => (t += c)).on("end", () => process.exit(t.includes(String.fromCodePoint(0x2014)) ? 1 : 0));\n', + ); + writeFileSync( + join(dir, 'cosyte-phi-scan.js'), + brokenPhi + ? 'process.exit(0);\n' + : [ + 'const fs = require("node:fs");', + // Read relative to the working directory, as the real scanner does, and exit 2 (never 1) when + // a named file cannot be read, so a missing plant can never pass for a found one. + 'try {', + ' const hit = process.argv.slice(2).some((f) => /\\d{3}-\\d{2}-\\d{4}/.test(fs.readFileSync(f, "utf8")));', + ' process.exit(hit ? 1 : 0);', + '} catch {', + ' process.exit(2);', + '}', + '', + ].join('\n'), + ); + writeFileSync(join(dir, 'package.json'), '{"type":"commonjs"}\n'); + return dir; +} + +test('filesUnder lists every file with forward slashes, and skips what an install, a build or a plant writes', () => { + const root = tree({ + 'a.txt': 'a', + 'src/b.mjs': 'b', + 'node_modules/x/index.js': 'x', + 'dist/index.mjs': 'd', + [`${PLANTED}/phi.txt`]: 'p', + }); + assert.deepEqual(filesUnder(root), ['a.txt', 'src/b.mjs']); +}); + +test('format-check fails on a carriage return, naming the file, and passes LF only', async () => { + const crlf = tree({ 'src/index.mjs': 'line one\r\nline two\r\n', 'README.md': 'ok\n' }); + assert.deepEqual(carriageReturns(crlf), ['src/index.mjs']); + assert.deepEqual(await verb('format-check', crlf), { code: 1, lines: ['format-check: src/index.mjs holds a carriage return'] }); + + const lf = tree({ 'src/index.mjs': 'line one\nline two\n', 'README.md': 'ok\n' }); + assert.deepEqual(await verb('format-check', lf), { code: 0, lines: ['format-check: 2 files, LF line endings only'] }); +}); + +test('runtimeOf names Bun, then Deno, then Node', () => { + assert.equal(runtimeOf({ bun: '1.4.4', node: '24.3.0' }), 'bun 1.4.4'); + assert.equal(runtimeOf({ deno: '2.9.8', node: '24.2.0' }), 'deno 2.9.8'); + assert.equal(runtimeOf({ node: '24.0.0' }), 'node 24.0.0'); +}); + +test('platformProblem holds each runner to its system, and says nothing off a runner', () => { + assert.equal(platformProblem(undefined, 'win32'), null); + assert.equal(platformProblem('Linux', 'linux'), null); + assert.equal(platformProblem('Windows', 'win32'), null); + assert.equal(platformProblem('macOS', 'darwin'), null); + assert.equal(platformProblem('Windows', 'linux'), 'RUNNER_OS is Windows but process.platform is linux'); + assert.equal(platformProblem('Plan9', 'linux'), 'RUNNER_OS is Plan9, which this fixture does not know'); +}); + +test('test passes on the system the runner names, and fails on another one or on a broken module', async () => { + const good = tree({ 'src/index.mjs': GREETING }); + assert.deepEqual(await verb('test', good, { env: { RUNNER_OS: 'Linux' } }), { + code: 0, + lines: ['test: running under node 24.0.0 on linux', 'test: passed'], + }); + assert.deepEqual(await verb('test', good, { env: { RUNNER_OS: 'macOS' } }), { + code: 1, + lines: ['test: running under node 24.0.0 on linux', 'test: RUNNER_OS is macOS but process.platform is linux'], + }); + const broken = tree({ 'src/index.mjs': 'export const greeting = "something else";\n' }); + assert.deepEqual(await verb('test', broken, { versions: { deno: '2.9.8', node: '24.2.0' } }), { + code: 1, + lines: ['test: running under deno 2.9.8 on linux', 'test: src/index.mjs did not export the greeting'], + }); +}); + +test('build writes the three files the shared smoke step loads, and the CJS one carries the exports', async () => { + const root = tree({ 'src/index.mjs': GREETING, 'src/index.d.ts': 'export declare const greeting: string;\n' }); + assert.deepEqual(await verb('build', root), { code: 0, lines: ['build: wrote dist/index.mjs, dist/index.cjs and dist/index.d.ts'] }); + // A second build over an existing dist/ succeeds too. + assert.deepEqual(await verb('build', root), { code: 0, lines: ['build: wrote dist/index.mjs, dist/index.cjs and dist/index.d.ts'] }); + assert.equal(readFileSync(join(root, 'dist/index.mjs'), 'utf8'), GREETING); + assert.equal(readFileSync(join(root, 'dist/index.d.ts'), 'utf8'), 'export declare const greeting: string;\n'); + assert.equal( + readFileSync(join(root, 'dist/index.cjs'), 'utf8'), + '"use strict";\nmodule.exports = {"greeting":"hello from the CI caller fixture"};\n', + ); +}); + +test('attw fails on each export target with no file, and passes when every one is on disk', async () => { + const root = tree({ 'package.json': JSON.stringify(EXPORTS), 'dist/index.mjs': '', 'dist/index.d.ts': '' }); + assert.deepEqual(missingTargets(root, EXPORTS), ['./dist/index.cjs']); + assert.deepEqual(await verb('attw', root), { code: 1, lines: ['attw: ./dist/index.cjs is in the exports map but not on disk'] }); + writeFileSync(join(root, 'dist/index.cjs'), ''); + assert.deepEqual(await verb('attw', root), { code: 0, lines: ['attw: every export target is on disk'] }); +}); + +test('judge wants 0 on the clean input and 1 on the planted one, and nothing else', () => { + assert.deepEqual(judge('x', 0, 1), []); + assert.deepEqual(judge('x', 1, 1), ['x exited 1 on the clean input, not 0']); + assert.deepEqual(judge('x', 0, 0), ['x exited 0 on the planted input, not 1, so its clean verdict proves nothing']); + assert.deepEqual(judge('x', 2, null), [ + 'x exited 2 on the clean input, not 0', + 'x exited null on the planted input, not 1, so its clean verdict proves nothing', + ]); +}); + +test('lint passes clean text and finds the planted dash, and fails if either answer is wrong', async () => { + const clean = tree({ 'README.md': 'a plain - hyphen\n' }); + const ok = await verb('lint', clean, { binDir: stubs() }); + assert.deepEqual(ok, { + code: 0, + lines: [ + 'lint: the next run is fed a planted em dash, and must find it', + 'lint: the dash checker passed clean text and found a planted dash', + ], + }); + + const dashed = tree({ 'README.md': `a ${String.fromCodePoint(0x2014)} dash\n` }); + const red = await verb('lint', dashed, { binDir: stubs() }); + assert.equal(red.code, 1); + assert.equal(red.lines.at(-1), 'lint: cosyte-no-emdash exited 1 on the clean input, not 0'); + + const blind = await verb('lint', clean, { binDir: stubs({ brokenDash: true }) }); + assert.equal(blind.code, 1); + assert.equal(blind.lines.at(-1), 'lint: cosyte-no-emdash exited 0 on the planted input, not 1, so its clean verdict proves nothing'); +}); + +test('phi-scan passes the tree and finds the planted SSN shape, removes the plant, and fails if either answer is wrong', async () => { + // A plant directory left behind by an interrupted run is reused, not refused. + const root = tree({ 'README.md': 'nothing here\n', [`${PLANTED}/stale.txt`]: 'left behind\n' }); + const ok = await verb('phi-scan', root, { binDir: stubs() }); + assert.deepEqual(ok, { + code: 0, + lines: [ + 'phi-scan: the next run reads a planted, synthetic SSN shape, and must find it', + 'phi-scan: the PHI scanner passed the fixture and found a planted SSN shape', + ], + }); + assert.equal(existsSync(join(root, PLANTED)), false, 'the planted input is removed'); + + const blind = await verb('phi-scan', root, { binDir: stubs({ brokenPhi: true }) }); + assert.equal(blind.code, 1); + assert.equal(blind.lines.at(-1), 'phi-scan: cosyte-phi-scan exited 0 on the planted input, not 1, so its clean verdict proves nothing'); + assert.equal(existsSync(join(root, PLANTED)), false, 'the planted input is removed after a failure too'); +}); + +test('an unknown verb is refused with the usage line', async () => { + assert.deepEqual(await verb('nope', FIXTURE), { code: 2, lines: ['usage: node tool.mjs , not nope'] }); +}); + +test('run from the command line, the fixture passes its own test and its own format check', () => { + const env = { ...process.env }; + delete env.RUNNER_OS; + for (const name of ['test', 'format-check']) { + const result = spawnSync(process.execPath, ['tool.mjs', name], { cwd: FIXTURE, encoding: 'utf8', env }); + assert.equal(result.status, 0, `${name}: ${result.stdout}${result.stderr}`); + } + const refused = spawnSync(process.execPath, ['tool.mjs', 'nope'], { cwd: FIXTURE, encoding: 'utf8' }); + assert.equal(refused.status, 2); +}); + +test('a checker\'s own report reaches the job log, so a red can be read', () => { + // The installed layout, with a stub that speaks, and the fixture's tool copied beside it. + const root = tree({ + 'tool.mjs': readFileSync(join(FIXTURE, 'tool.mjs'), 'utf8'), + 'README.md': 'clean\n', + 'node_modules/@cosyte/script-utils/bin/cosyte-no-emdash.js': [ + 'let t = "";', + 'process.stdin.on("data", (c) => (t += c)).on("end", () => {', + ' console.log(`stub dash checker: ${t.length} characters`);', + ' process.exit(t.includes(String.fromCodePoint(0x2014)) ? 1 : 0);', + '});', + '', + ].join('\n'), + 'node_modules/@cosyte/script-utils/bin/package.json': '{"type":"commonjs"}\n', + }); + const result = spawnSync(process.execPath, ['tool.mjs', 'lint'], { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /^stub dash checker: \d+ characters$/m); + assert.match(result.stdout, /^stub dash checker: 6 characters$/m, 'the planted line is "a b" and a newline'); +}); diff --git a/test/ci-docs-content.test.mjs b/test/ci-docs-content.test.mjs index 98b767f..232737d 100644 --- a/test/ci-docs-content.test.mjs +++ b/test/ci-docs-content.test.mjs @@ -37,6 +37,12 @@ const EXISTING_INPUTS = [ 'expect-unpublished-deps', ]; +/** + * The inputs added after this change, by their own change, all after this one and none re-defaulting + * anything: `test/ci-systems.test.mjs` owns them. + */ +const LATER_INPUTS = ['working-directory', 'run-bun', 'bun-version', 'run-deno', 'deno-version']; + const indentOf = (line) => /^ */.exec(line)[0].length; /** The index of the first line matching `pattern`. */ @@ -96,7 +102,9 @@ test('the job set is unchanged, so this change adds no status check context', () const jobs = block(lineIndex(/^jobs:$/)) .filter((line) => /^ [a-z][\w-]*:$/.test(line)) .map((line) => line.trim().replace(':', '')); - assert.deepEqual(jobs, ['verify', 'prepublish', 'actionlint']); + // `bun` and `deno` were ADDED later, by their own change, after these three, and both are skipped + // unless a caller opts in: `test/ci-systems.test.mjs` owns them. This change added none. + assert.deepEqual(jobs, ['verify', 'prepublish', 'actionlint', 'bun', 'deno']); }); test('every job-level condition is unchanged, and no job gains `continue-on-error`', () => { @@ -143,7 +151,7 @@ test('EXACTLY ONE input is added, and its default is false', () => { const inputs = block(lineIndex(/^ inputs:$/)) .filter((line) => /^ [a-z][\w-]*:$/.test(line)) .map((line) => line.trim().replace(':', '')); - assert.deepEqual(inputs, [...EXISTING_INPUTS, INPUT], 'no input removed, renamed, or added beyond one'); + assert.deepEqual(inputs, [...EXISTING_INPUTS, INPUT, ...LATER_INPUTS], 'no input removed, renamed, or added beyond one'); const definition = block(lineIndex(new RegExp(`^ ${INPUT}:$`))).join('\n'); assert.match(definition, /^\s+type: boolean$/m); diff --git a/test/ci-systems.test.mjs b/test/ci-systems.test.mjs new file mode 100644 index 0000000..55effa2 --- /dev/null +++ b/test/ci-systems.test.mjs @@ -0,0 +1,304 @@ +// `ci.yml` on every system and runtime: the line ending step `verify` runs first on Windows, the +// opt-in `bun` and `deno` jobs, the `working-directory` input that lets this repository run the +// workflow against its own fixture, and the `systems` job in `self-check.yml` that does it. +// +// WHAT THIS FILE CAN PROVE AND WHAT IT CANNOT. It reads the two workflow files and runs the one shell +// script whose logic is ours (the deno step's PATH rewrite) against stubs. It cannot prove that a step +// works on a Windows or a macOS runner: only a runner can, and `self-check.yml`'s `systems` job is +// that proof, on every pull request. What this file holds is the surface a caller depends on (job +// ids, inputs, defaults) and the wiring that makes the `systems` run mean something. +// +// The parse is line based, for the reason `test/ci-docs-content.test.mjs` gives: `ci.yml`'s matrix +// nests deeper than the shared reader models, and this repository has no YAML library to install. + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CI = readFileSync(join(REPO, '.github/workflows/ci.yml'), 'utf8'); +const SELF_CHECK = readFileSync(join(REPO, '.github/workflows/self-check.yml'), 'utf8'); + +const indentOf = (line) => /^ */.exec(line)[0].length; + +/** The lines of the block `header` opens in `text`, comment-only lines dropped. */ +function block(text, header) { + const lines = text.split('\n'); + const at = lines.indexOf(header); + assert.notEqual(at, -1, `no line reads exactly ${JSON.stringify(header)}`); + const base = indentOf(header); + const out = []; + for (const line of lines.slice(at + 1)) { + if (line.trim() === '' || line.trim().startsWith('#')) continue; + if (indentOf(line) <= base) break; + out.push(line); + } + return out; +} + +/** The job ids directly under `jobs:`. */ +function jobIds(text) { + return block(text, 'jobs:') + .filter((line) => /^ {2}[a-z][\w-]*:$/.test(line)) + .map((line) => line.trim().slice(0, -1)); +} + +/** The input names under `workflow_call.inputs`, in file order. */ +function inputNames() { + return block(CI, ' inputs:') + .filter((line) => /^ {6}[a-z][\w-]*:$/.test(line)) + .map((line) => line.trim().slice(0, -1)); +} + +/** One input's `type`, `default` and `required`, as written. */ +function inputOf(name) { + const body = block(CI, ` ${name}:`); + const value = (key) => body.find((line) => line.startsWith(` ${key}: `))?.slice(10 + key.length); + return { type: value('type'), default: value('default'), required: value('required') }; +} + +/** A job's steps, one string each, with the step's own lines joined. */ +function steps(text, job) { + const body = block(text, ` ${job}:`); + const at = body.indexOf(' steps:'); + assert.notEqual(at, -1, `${job} has no steps`); + const out = []; + for (const line of body.slice(at + 1)) { + if (/^ {6}- /.test(line)) out.push(line); + else out[out.length - 1] += `\n${line}`; + } + return out; +} + +/** The job-level keys of a job, as their lines. */ +function jobKeys(text, job) { + return block(text, ` ${job}:`).filter((line) => indentOf(line) === 4); +} + +/** The `run: |` body of the step named `name` in `job`, dedented to what bash receives. */ +function runScript(job, name) { + const step = steps(CI, job).find((entry) => entry.startsWith(` - name: ${name}\n`)); + assert.ok(step, `${job} has no step named ${JSON.stringify(name)}`); + const lines = step.split('\n'); + const at = lines.indexOf(' run: |'); + assert.notEqual(at, -1, `${name} has no run: | block`); + return `${lines.slice(at + 1).map((line) => line.slice(10)).join('\n')}\n`; +} + +// --------------------------------------------------------------------------- +// THE CALLER-VISIBLE SURFACE +// --------------------------------------------------------------------------- + +test('the new inputs come after every existing one, and each is off or inert by default', () => { + assert.deepEqual(inputNames(), [ + 'node-versions', + 'os', + 'run-phi-scan', + 'run-actionlint', + 'run-prepublish-manifest-lint', + 'run-prepublish-install', + 'expect-unpublished-deps', + 'check-docs-content', + 'working-directory', + 'run-bun', + 'bun-version', + 'run-deno', + 'deno-version', + ]); + // THE DEFAULTS ARE WHAT KEEP A MERGE FROM CHANGING ANY CALLER: both runtimes off, and the + // directory at the repository root, where every step reads what it read before. + assert.deepEqual(inputOf('working-directory'), { type: 'string', default: '"."', required: undefined }); + assert.deepEqual(inputOf('run-bun'), { type: 'boolean', default: 'false', required: undefined }); + assert.deepEqual(inputOf('run-deno'), { type: 'boolean', default: 'false', required: undefined }); + assert.deepEqual(inputOf('bun-version'), { type: 'string', default: '"1.4.4"', required: undefined }); + assert.deepEqual(inputOf('deno-version'), { type: 'string', default: '"2.9.8"', required: undefined }); +}); + +test('bun and deno are added after the existing jobs, each behind its own input, and nothing depends on them', () => { + assert.deepEqual(jobIds(CI), ['verify', 'prepublish', 'actionlint', 'bun', 'deno']); + assert.deepEqual(jobKeys(CI, 'bun').slice(0, 3), [ + ' if: ${{ inputs.run-bun }}', + ' runs-on: ubuntu-latest', + ' timeout-minutes: 30', + ]); + assert.deepEqual(jobKeys(CI, 'deno').slice(0, 3), [ + ' if: ${{ inputs.run-deno }}', + ' runs-on: ubuntu-latest', + ' timeout-minutes: 30', + ]); + // A `needs:` would let a skipped or failed runtime job skip something a caller requires: README.md, + // "What a skipped required context does to a merge". + assert.equal(CI.split('\n').some((line) => /^ {4}needs:/.test(line)), false); +}); + +test('each runtime job installs what verify installs, then runs the caller\'s own test script under that runtime', () => { + const bun = steps(CI, 'bun'); + const deno = steps(CI, 'deno'); + for (const [name, list] of [['bun', bun], ['deno', deno]]) { + assert.ok(list.some((step) => step.includes('run: pnpm install --frozen-lockfile')), `${name} installs from the lockfile`); + assert.ok(list.some((step) => step.includes('node-version: ${{ fromJSON(inputs.node-versions)[0] }}')), `${name} installs on the lowest Node line`); + assert.ok(list.every((step) => !step.includes('continue-on-error')), `${name} swallows no failure`); + } + assert.ok(bun.some((step) => /oven-sh\/setup-bun@[0-9a-f]{40} # v2\.2\.0\n {8}with:\n {10}bun-version: \$\{\{ inputs\.bun-version \}\}/.test(step))); + assert.ok(deno.some((step) => /denoland\/setup-deno@[0-9a-f]{40} # v2\.0\.5\n {8}with:\n {10}deno-version: \$\{\{ inputs\.deno-version \}\}/.test(step))); + // `--bun` is the flag that moves a script's `node` onto Bun; `bun run test` alone runs it on Node. + assert.match(runScript('bun', 'The test suite under Bun'), /^bun --bun run test$/m); + assert.match(runScript('deno', 'The test suite under Deno'), /^deno task test$/m); +}); + +// --------------------------------------------------------------------------- +// VERIFY ON EVERY SYSTEM +// --------------------------------------------------------------------------- + +test('every multi-line script in verify, bun and deno names bash, so Windows does not run it in PowerShell', () => { + for (const job of ['verify', 'bun', 'deno']) { + for (const step of steps(CI, job).filter((entry) => entry.includes('run: |'))) { + assert.match(step, /^ {8}shell: bash$/m, `${job}: ${step.split('\n')[0].trim()}`); + } + } +}); + +test('at the default directory, every job reads the package where it read it before the input existed', () => { + for (const job of ['verify', 'bun', 'deno']) { + const body = block(CI, ` ${job}:`); + assert.deepEqual(body.slice(0, body.indexOf(' steps:')).filter((line) => /^ {4}defaults:|^ {6}run:$|^ {8}working-directory:/.test(line)), [ + ' defaults:', + ' run:', + ' working-directory: ${{ inputs.working-directory }}', + ]); + const all = steps(CI, job).join('\n'); + // `package.json` is pnpm/action-setup's own default, and an empty `cache-dependency-path` is unset. + assert.match(all, /package_json_file: \$\{\{ inputs\.working-directory == '\.' && 'package\.json' \|\| format\('\{0\}\/package\.json', inputs\.working-directory\) \}\}/); + assert.match(all, /cache-dependency-path: \$\{\{ inputs\.working-directory != '\.' && format\('\{0\}\/pnpm-lock\.yaml', inputs\.working-directory\) \|\| '' \}\}/); + } + // `prepublish` and `actionlint` read the repository root and nothing else, as before. + assert.equal(block(CI, ' prepublish:').some((line) => line.includes('working-directory')), false); + assert.equal(block(CI, ' actionlint:').some((line) => line.includes('working-directory')), false); +}); + +// --------------------------------------------------------------------------- +// THE DENO STEP'S PATH REWRITE, RUN AGAINST STUBS +// --------------------------------------------------------------------------- + +/** An executable shell script at `path`. */ +function executable(path, body) { + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); +} + +/** + * Run the deno step's script with PATH = `extra` dirs + a dir of real tools + a dir with a stub deno. + * The stub records what `node` and `helper` resolve to when it runs, and its arguments. + */ +function runDenoStep({ withNodeDir = true, emptyEntry = false, denoExit = 0 } = {}) { + const root = mkdtempSync(join(tmpdir(), 'ci-systems-')); + const tools = join(root, 'tools'); + const withNode = join(root, 'with-node'); + const denoDir = join(root, 'deno-bin'); + const cwd = join(root, 'cwd'); + const temp = join(root, 'runner-temp'); + for (const dir of [tools, withNode, denoDir, cwd, temp]) mkdirSync(dir); + for (const tool of ['rm', 'mkdir', 'ln', 'head', 'cat', 'sh']) { + const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim(); + symlinkSync(found, join(tools, tool)); + } + executable(join(withNode, 'node'), 'echo real-node'); + executable(join(withNode, 'helper'), 'echo helper'); + executable(join(cwd, 'node'), 'echo cwd-node'); + const record = join(root, 'record'); + executable( + join(denoDir, 'deno'), + [ + `echo "args=$*" >> '${record}'`, + `echo "node=$(command -v node || echo none)" >> '${record}'`, + `echo "helper=$(command -v helper || echo none)" >> '${record}'`, + 'if [ "$1" = "--version" ]; then echo "deno 0.0.0-stub"; exit 0; fi', + `exit ${denoExit}`, + ].join('\n'), + ); + const entries = [...(emptyEntry ? [''] : []), ...(withNodeDir ? [withNode] : []), tools, denoDir]; + const bash = spawnSync('sh', ['-c', 'command -v bash'], { encoding: 'utf8' }).stdout.trim(); + const result = spawnSync(bash, ['-c', runScript('deno', 'The test suite under Deno')], { + cwd, + env: { PATH: entries.join(':'), RUNNER_TEMP: temp }, + encoding: 'utf8', + }); + const recorded = existsSync(record) ? readFileSync(record, 'utf8') : ''; + return { ...result, recorded, temp, withNode }; +} + +test('the deno step takes node off PATH, keeps everything beside it, and runs the suite with deno task', () => { + const run = runDenoStep(); + assert.equal(run.status, 0, run.stderr); + const mirror = join(run.temp, 'path-without-node', '1'); + assert.equal( + run.recorded, + [ + 'args=--version', + 'node=none', + `helper=${mirror}/helper`, + 'args=task test', + 'node=none', + `helper=${mirror}/helper`, + '', + ].join('\n'), + ); + assert.match(run.stdout, new RegExp(`deno: ${run.withNode} holds a node, so PATH names ${mirror} in its place`)); + assert.match(run.stdout, /^deno: deno 0\.0\.0-stub$/m); +}); + +test('the deno step leaves a PATH with no node alone', () => { + const run = runDenoStep({ withNodeDir: false }); + assert.equal(run.status, 0, run.stderr); + assert.match(run.recorded, /^args=task test$/m); + assert.doesNotMatch(run.stdout, /holds a node/); + assert.equal(existsSync(join(run.temp, 'path-without-node')), false); +}); + +test('the deno step refuses to run the suite while a node is still reachable', () => { + // An empty PATH entry is the working directory, which holds a `node` here and cannot be mirrored. + const run = runDenoStep({ emptyEntry: true }); + assert.equal(run.status, 1); + assert.match(run.stdout, /^::error::a node is still on PATH at \.\/node, so the suite would run under Node rather than Deno$/m); + assert.equal(run.recorded, '', 'deno must never be started'); +}); + +test('the deno step fails when the suite fails', () => { + const run = runDenoStep({ denoExit: 3 }); + assert.equal(run.status, 3); + assert.match(run.recorded, /^args=task test$/m); +}); + +// --------------------------------------------------------------------------- +// THE PROOF: self-check.yml RUNS ci.yml ON EVERY SYSTEM AND RUNTIME +// --------------------------------------------------------------------------- + +test('self-check runs ci.yml itself on all three systems and both runtimes, against the fixture', () => { + assert.deepEqual(jobIds(SELF_CHECK), ['actionlint', 'scripts', 'package-lists', 'systems']); + assert.deepEqual(block(SELF_CHECK, ' systems:'), [ + ' uses: ./.github/workflows/ci.yml', + ' with:', + ' working-directory: test/fixtures/ci-caller', + ' os: \'["ubuntu-latest", "windows-latest", "macos-latest"]\'', + ' run-phi-scan: true', + ' check-docs-content: true', + ' run-bun: true', + ' run-deno: true', + ' run-actionlint: false', + ' run-prepublish-manifest-lint: false', + ' run-prepublish-install: false', + ]); + // The run proves something only if the fixture is a package `verify` can install and every one of + // its steps calls a script the fixture defines. + const fixture = join(REPO, 'test/fixtures/ci-caller'); + const manifest = JSON.parse(readFileSync(join(fixture, 'package.json'), 'utf8')); + for (const script of ['typecheck', 'lint', 'format:check', 'phi-scan', 'test', 'test:coverage', 'build', 'attw']) { + assert.equal(typeof manifest.scripts[script], 'string', `the fixture defines no ${script} script`); + } + assert.ok(existsSync(join(fixture, 'pnpm-lock.yaml')), 'verify installs with --frozen-lockfile'); + assert.ok(existsSync(join(fixture, 'docs-content/sidebars.json')), 'check-docs-content needs docs-content/sidebars.json'); +}); diff --git a/test/fixtures/ci-caller/.gitignore b/test/fixtures/ci-caller/.gitignore new file mode 100644 index 0000000..849a505 --- /dev/null +++ b/test/fixtures/ci-caller/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.planted/ diff --git a/test/fixtures/ci-caller/docs-content/guides/usage.md b/test/fixtures/ci-caller/docs-content/guides/usage.md new file mode 100644 index 0000000..a61e770 --- /dev/null +++ b/test/fixtures/ci-caller/docs-content/guides/usage.md @@ -0,0 +1,3 @@ +# Usage + +There is nothing to use. Go back to the [introduction](../intro.md). diff --git a/test/fixtures/ci-caller/docs-content/intro.md b/test/fixtures/ci-caller/docs-content/intro.md new file mode 100644 index 0000000..1edec40 --- /dev/null +++ b/test/fixtures/ci-caller/docs-content/intro.md @@ -0,0 +1,3 @@ +# The CI caller fixture + +A package that exists only so the shared CI can run against it. See [usage](./guides/usage.md). diff --git a/test/fixtures/ci-caller/docs-content/sidebars.json b/test/fixtures/ci-caller/docs-content/sidebars.json new file mode 100644 index 0000000..2ee6deb --- /dev/null +++ b/test/fixtures/ci-caller/docs-content/sidebars.json @@ -0,0 +1,3 @@ +{ + "docs": ["intro", "guides/usage"] +} diff --git a/test/fixtures/ci-caller/package.json b/test/fixtures/ci-caller/package.json new file mode 100644 index 0000000..a7eea5b --- /dev/null +++ b/test/fixtures/ci-caller/package.json @@ -0,0 +1,28 @@ +{ + "name": "ci-caller-fixture", + "version": "0.0.0", + "private": true, + "description": "A package the shared CI runs against on every system, so a change to ci.yml is proven before a caller adopts it.", + "type": "module", + "packageManager": "pnpm@10.34.5", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "typecheck": "node --check src/index.mjs", + "lint": "node tool.mjs lint", + "format:check": "node tool.mjs format-check", + "phi-scan": "node tool.mjs phi-scan", + "test": "node tool.mjs test", + "test:coverage": "node tool.mjs test", + "build": "node tool.mjs build", + "attw": "node tool.mjs attw" + }, + "devDependencies": { + "@cosyte/script-utils": "0.1.8" + } +} diff --git a/test/fixtures/ci-caller/pnpm-lock.yaml b/test/fixtures/ci-caller/pnpm-lock.yaml new file mode 100644 index 0000000..e7d6100 --- /dev/null +++ b/test/fixtures/ci-caller/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@cosyte/script-utils': + specifier: 0.1.8 + version: 0.1.8 + +packages: + + '@cosyte/script-utils@0.1.8': + resolution: {integrity: sha512-d5+N2hVp/MqZ+MCAO91sJjFL1mRQU6gFlJOlMKdEF4iUKHWLNY/Vh1CTNSK6ebk9SxtrU+4GC5LGGCJWKoldbA==} + engines: {node: '>=22.14'} + hasBin: true + +snapshots: + + '@cosyte/script-utils@0.1.8': {} diff --git a/test/fixtures/ci-caller/scripts/phi-allow-list.txt b/test/fixtures/ci-caller/scripts/phi-allow-list.txt new file mode 100644 index 0000000..7b32cda --- /dev/null +++ b/test/fixtures/ci-caller/scripts/phi-allow-list.txt @@ -0,0 +1 @@ +# Synthetic values this fixture declares. It declares none. diff --git a/test/fixtures/ci-caller/src/index.d.ts b/test/fixtures/ci-caller/src/index.d.ts new file mode 100644 index 0000000..58ffe05 --- /dev/null +++ b/test/fixtures/ci-caller/src/index.d.ts @@ -0,0 +1 @@ +export declare const greeting: string; diff --git a/test/fixtures/ci-caller/src/index.mjs b/test/fixtures/ci-caller/src/index.mjs new file mode 100644 index 0000000..e6ee4c6 --- /dev/null +++ b/test/fixtures/ci-caller/src/index.mjs @@ -0,0 +1 @@ +export const greeting = "hello from the CI caller fixture"; diff --git a/test/fixtures/ci-caller/tool.mjs b/test/fixtures/ci-caller/tool.mjs new file mode 100644 index 0000000..074b5b5 --- /dev/null +++ b/test/fixtures/ci-caller/tool.mjs @@ -0,0 +1,173 @@ +// The scripts of the CI caller fixture: a package `self-check.yml` runs `ci.yml` against on Linux, +// Windows and macOS, and under Bun and Deno, so a change to the shared CI is proven on every system +// before any caller adopts it. It imports `node:*` only and has no build step, so the same file runs +// unchanged under Node, Bun and Deno. `test/ci-caller-fixture.test.mjs` tests it. +// +// Each verb stands in for one of a caller's own scripts, and each fails on the hazard it is there to +// catch: +// format-check a carriage return in any file: a Windows checkout that turned LF into CRLF, which +// every caller's formatter reports as a whole tree of unformatted files +// lint the dash checker callers run (`cosyte-no-emdash` from `@cosyte/script-utils`) +// failing to pass the fixture's own text, or failing to find a planted em dash +// phi-scan the PHI scanner callers run (`cosyte-phi-scan`) failing to pass the fixture, or +// failing to find a planted, synthetic SSN shape +// test a run on a system other than the one the runner says it is, and a source module +// that does not load +// build writes the dist/ the shared ESM and CJS smoke step loads +// attw a target in the package's exports map with no file behind it +// +// The planted inputs are what make a pass mean something: a checker that cannot run on a system, +// or that runs and reads nothing, passes clean text too. + +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** The value of RUNNER_OS on each hosted runner, and the `process.platform` it implies. */ +export const PLATFORM_OF_RUNNER = { Linux: "linux", Windows: "win32", macOS: "darwin" }; + +/** Where the planted inputs are written, ignored by git and by every check here. */ +export const PLANTED = ".planted"; + +/** Directories a check never reads: what an install, a build or a plant writes. */ +const SKIPPED = new Set(["node_modules", "dist", PLANTED]); + +/** Every file under `dir`, recursively, as paths relative to `root` with forward slashes. */ +export function filesUnder(root, dir = root) { + const found = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (SKIPPED.has(entry.name)) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) found.push(...filesUnder(root, path)); + else found.push(relative(root, path).split("\\").join("/")); + } + return found.sort(); +} + +/** The files under `root` holding a carriage return. */ +export function carriageReturns(root) { + return filesUnder(root).filter((file) => readFileSync(join(root, file)).includes(13)); +} + +/** The name and version of the runtime executing this file. */ +export function runtimeOf(versions) { + if (versions.bun) return `bun ${versions.bun}`; + if (versions.deno) return `deno ${versions.deno}`; + return `node ${versions.node}`; +} + +/** Why the runner and the platform disagree, or null when they agree or no runner is named. */ +export function platformProblem(runnerOs, platform) { + if (runnerOs === undefined) return null; + const expected = PLATFORM_OF_RUNNER[runnerOs]; + if (expected === undefined) return `RUNNER_OS is ${runnerOs}, which this fixture does not know`; + if (expected !== platform) return `RUNNER_OS is ${runnerOs} but process.platform is ${platform}`; + return null; +} + +/** The export targets of `manifest` with no file under `root`. */ +export function missingTargets(root, manifest) { + return Object.values(manifest.exports["."]).filter((target) => !existsSync(join(root, target))); +} + +/** Run one of `@cosyte/script-utils`' bins from `binDir` with this runtime, and return its exit status. */ +export function runChecker(binDir, root, name, args, input) { + const result = spawnSync(process.execPath, [join(binDir, `${name}.js`), ...args], { + cwd: root, + input: input ?? "", + stdio: ["pipe", "inherit", "inherit"], + }); + return result.status; +} + +/** + * Judge a checker by two runs: the clean input must pass (0) and the planted one must be found (1). + * + * @returns The problems, empty when both runs answered as they must. + */ +export function judge(name, cleanStatus, plantedStatus) { + const problems = []; + if (cleanStatus !== 0) problems.push(`${name} exited ${cleanStatus} on the clean input, not 0`); + if (plantedStatus !== 1) { + problems.push(`${name} exited ${plantedStatus} on the planted input, not 1, so its clean verdict proves nothing`); + } + return problems; +} + +/** Load the fixture's source module. */ +async function loadSource(root) { + return import(pathToFileURL(join(root, "src", "index.mjs")).href); +} + +/** Print each problem and turn the list into an exit code. */ +function report(verb, problems, passed, log) { + for (const problem of problems) log(`${verb}: ${problem}`); + if (problems.length > 0) return 1; + log(`${verb}: ${passed}`); + return 0; +} + +/** + * Run one verb against the fixture at `root`. + * + * @returns 0 when the verb passed, 1 when it found something, 2 for a verb it does not know. + */ +export async function run(verb, context) { + const { root, env, platform, versions, log } = context; + const binDir = context.binDir ?? join(root, "node_modules", "@cosyte", "script-utils", "bin"); + if (verb === "format-check") { + const found = carriageReturns(root).map((file) => `${file} holds a carriage return`); + return report(verb, found, `${filesUnder(root).length} files, LF line endings only`, log); + } + if (verb === "lint") { + const text = filesUnder(root).map((file) => readFileSync(join(root, file), "utf8")).join("\n"); + const clean = runChecker(binDir, root, "cosyte-no-emdash", ["--stdin", "the fixture's files"], text); + log("lint: the next run is fed a planted em dash, and must find it"); + const dash = String.fromCodePoint(0x2014); + const planted = runChecker(binDir, root, "cosyte-no-emdash", ["--stdin", "a planted dash"], `a ${dash} b\n`); + return report(verb, judge("cosyte-no-emdash", clean, planted), "the dash checker passed clean text and found a planted dash", log); + } + if (verb === "phi-scan") { + const clean = runChecker(binDir, root, "cosyte-phi-scan", []); + log("phi-scan: the next run reads a planted, synthetic SSN shape, and must find it"); + const dir = join(root, PLANTED); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "phi.txt"), `synthetic ${["123", "45", "6789"].join("-")}\n`); + let planted; + try { + planted = runChecker(binDir, root, "cosyte-phi-scan", [`${PLANTED}/phi.txt`]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + return report(verb, judge("cosyte-phi-scan", clean, planted), "the PHI scanner passed the fixture and found a planted SSN shape", log); + } + if (verb === "test") { + log(`test: running under ${runtimeOf(versions)} on ${platform}`); + const problem = platformProblem(env.RUNNER_OS, platform); + const problems = problem === null ? [] : [problem]; + const source = await loadSource(root); + if (source.greeting !== "hello from the CI caller fixture") problems.push("src/index.mjs did not export the greeting"); + return report(verb, problems, "passed", log); + } + if (verb === "build") { + const source = await loadSource(root); + mkdirSync(join(root, "dist"), { recursive: true }); + copyFileSync(join(root, "src", "index.mjs"), join(root, "dist", "index.mjs")); + copyFileSync(join(root, "src", "index.d.ts"), join(root, "dist", "index.d.ts")); + writeFileSync(join(root, "dist", "index.cjs"), `"use strict";\nmodule.exports = ${JSON.stringify({ ...source })};\n`); + return report(verb, [], "wrote dist/index.mjs, dist/index.cjs and dist/index.d.ts", log); + } + if (verb === "attw") { + const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const missing = missingTargets(root, manifest).map((target) => `${target} is in the exports map but not on disk`); + return report(verb, missing, "every export target is on disk", log); + } + log(`usage: node tool.mjs , not ${verb}`); + return 2; +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + const context = { root: process.cwd(), env: process.env, platform: process.platform, versions: process.versions, log: console.log }; + process.exitCode = await run(process.argv[2], context); +} diff --git a/test/skipped-required-context.test.mjs b/test/skipped-required-context.test.mjs index 6eb5594..0a753bc 100644 --- a/test/skipped-required-context.test.mjs +++ b/test/skipped-required-context.test.mjs @@ -770,7 +770,9 @@ test('AC-9: the tree as this change leaves it passes, and every job that declare /** Every job id of every published reusable, at the pin. */ const PUBLISHED_JOB_IDS = { - 'ci.yml': ['verify', 'prepublish', 'actionlint'], + // ADDED, NOT RENAMED: `bun` and `deno` are new contexts no caller's ruleset names, each skipped + // unless the caller sets its input, and `verify`, `prepublish` and `actionlint` are where they were. + 'ci.yml': ['verify', 'prepublish', 'actionlint', 'bun', 'deno'], 'codeql.yml': ['analyze'], 'drift-check.yml': ['drift'], // ADDED, NOT RENAMED, and the distinction is the one this table exists to make legible. Both @@ -819,7 +821,8 @@ const EVERY_JOB_ID = { 'no-emdash.yml': ['no-emdash', 'no-emdash-messages'], 'org-defaults-coverage.yml': ['org-defaults-coverage'], 'publish-reference.yml': ['publish-reference'], - 'self-check.yml': ['actionlint', 'scripts', 'package-lists'], + // ADDED, NOT RENAMED: `systems` runs `ci.yml` against the fixture on every system and runtime. + 'self-check.yml': ['actionlint', 'scripts', 'package-lists', 'systems'], 'self-codeql.yml': ['codeql'], 'self-pr-title.yml': ['pr-title'], 'self-scorecard.yml': ['scorecard'], diff --git a/test/workflow-sha.test.mjs b/test/workflow-sha.test.mjs index 60ece20..ccffec8 100644 --- a/test/workflow-sha.test.mjs +++ b/test/workflow-sha.test.mjs @@ -471,7 +471,8 @@ test('THE JOB IDS OF BOTH PUBLISHED WORKFLOWS ARE UNCHANGED, in the file and thr // reads ` / `, so renaming one here detaches that entry on thirteen // repositories with no error anywhere and no re-run that undoes it. Nothing in this change may // move one, and both files are read because both are required contexts somewhere. - assert.deepEqual(jobIds(CI, read(CI)), ['verify', 'prepublish', 'actionlint']); + // `bun` and `deno` are ADDED, not renamed: new contexts, each skipped unless the caller opts in. + assert.deepEqual(jobIds(CI, read(CI)), ['verify', 'prepublish', 'actionlint', 'bun', 'deno']); // `finalize` is ADDED, not renamed: a new context no caller's ruleset names yet. assert.deepEqual(jobIds(RELEASE, read(RELEASE)), ['version', 'release', 'finalize']); assert.deepEqual( From 8d74780058169e014343e41c0d8ff218563a2854 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 9 Nov 2026 23:25:18 -0500 Subject: [PATCH 2/6] test(ci): require the dash checker to refuse on macOS, and document every system and runtime The first systems run showed the dash checker from @cosyte/script-utils cannot run on macOS: it needs GNU grep's -P, and the runner's BSD grep has none, so it refuses every input. The fixture's lint now requires that refusal on macOS and turns red the day the checker passes there. The same run showed actions/checkout on windows-latest writes LF line endings, so verify needs no line ending step; the fixture's format check stays as the tripwire. README.md gains "Every system and runtime", and CHANGELOG.md's Unreleased section says what a caller does to opt in. Claude-Session: https://claude.ai/code/session_016orFiR8mq7oCYo77QDTw2U --- CHANGELOG.md | 20 +++++++ README.md | 90 +++++++++++++++++++++++++++++++- test/ci-caller-fixture.test.mjs | 44 ++++++++++++++-- test/ci-systems.test.mjs | 6 +-- test/fixtures/ci-caller/tool.mjs | 34 +++++++++--- 5 files changed, 178 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e653a4f..045f4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,26 @@ so the next change to this file moves those lines under a heading with that name ## Unreleased +- `ci.yml` takes two opt-in inputs, `run-bun` and `run-deno`, both `false` by default, which add the + jobs `bun` and `deno`: each installs with pnpm from the committed lockfile, as `verify` does, and + runs the caller's own `test` script under that runtime, `bun --bun run test` and `deno task test` + (with `node` taken off PATH, so a script's literal `node` runs on Deno too). A caller that passes + nothing sees two skipped checks, `ci / bun` and `ci / deno`, and no other change. To opt in, add + `run-bun: true` and `run-deno: true` under `with:`; `bun-version` (default `"1.4.4"`) and + `deno-version` (default `"2.9.8"`) pick the runtime. Require `ci / bun` or `ci / deno` in a ruleset + only while its input is on, since a skipped job satisfies a required context. +- To run `verify` on every system, pass + `os: '["ubuntu-latest", "windows-latest", "macos-latest"]'`; the default is still + `'["ubuntu-latest"]'`. Each new leg is a new check, `ci / verify (, )`. Every shared step of + `verify` is now proven on all three systems by `self-check.yml`'s new `systems` job, which runs + `ci.yml` itself against `test/fixtures/ci-caller/` (README.md, "Every system and runtime"). One + limit it found: the dash checker in `@cosyte/script-utils` cannot run on macOS, where grep has no + `-P`; it refuses rather than passing, so a caller whose own `lint` runs it reds on macOS. +- `ci.yml` takes a `working-directory` input, `"."` by default, naming where `verify`, `bun` and + `deno` find `package.json`. It exists so this repository can run the workflow against its fixture; + at `"."` every step reads what it read before. `prepublish` and `actionlint` ignore it. +- Job ids `verify`, `prepublish` and `actionlint`, every existing input and default, secrets and + permissions are unchanged. - `ci.yml`'s and `release.yml`'s `expect-unpublished-deps` default to empty instead of `@cosyte/fhir=blocked`. That entry dates from before `@cosyte/fhir` was on npm; it is published now, so the entry was stale and every caller's install gates printed a notice saying the diff --git a/README.md b/README.md index 04f1379..9bb3125 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ thin caller, so the pipeline is defined once here. All actions are pinned to com | Workflow | Purpose | Used by | |---|---|---| -| [`ci.yml`](.github/workflows/ci.yml) | typecheck · lint(`--max-warnings=0`) · format:check · [PHI scan] · [docs-content] · test · coverage (gating) · build · `attw` · dual ESM/CJS smoke · actionlint · **the two pre-publish layers**. Everything before those reads the working tree, where the monorepo's own resolution is in scope; the pre-publish layers ask the consumer's question **before** anything is published, which is the only place it can be prevented | every parser | +| [`ci.yml`](.github/workflows/ci.yml) | typecheck · lint(`--max-warnings=0`) · format:check · [PHI scan] · [docs-content] · test · coverage (gating) · build · `attw` · dual ESM/CJS smoke · actionlint · **the two pre-publish layers** · [the test suite under Bun] · [under Deno], on every system the caller lists ([Every system and runtime](#every-system-and-runtime)). Everything before those reads the working tree, where the monorepo's own resolution is in scope; the pre-publish layers ask the consumer's question **before** anything is published, which is the only place it can be prevented | every parser | | [`release.yml`](.github/workflows/release.yml) | **three jobs.** `version` (no environment): **the release environment gate** → derive the release notes → **tag the version commit, before anything is published** → verify → open or refresh the "Version Packages" PR, with **no publish command and no npm credential**. `release` (`environment: release`, `needs: version`, runs only on a version commit): **the publish-path floor gate** → npm publish **through npm trusted publishing (OIDC), with no npm token**, and with provenance, **directly by default**, or **staged** for a maintainer to promote when the caller sets `publish-mode: staged` and the package already exists → docs artifacts → GitHub release **with derived notes** → `repository_dispatch` to `cosyte/docs` → **post-publish install gate**. On the staged arm the release and the dispatch wait, in the same run, for a maintainer to promote the version (`promotion-window-minutes`, default 120). `finalize` (no environment, `contents: write` only): on a later run, the GitHub release and the dispatch for a version published without its release (a staged version **promoted after its own run stopped waiting**, or a direct publish whose release step never finished), at the tag on the commit that released it, with no npm credential. **Each package's trusted-publisher entry on npmjs.com names the package's own repository and `release.yml`, the caller, never `cosyte/.github`**, because npm validates the calling workflow ([How a release authenticates to npm](#how-a-release-authenticates-to-npm)). The environment gate runs first in the **un-gated** job, so it runs on every path, and refuses the run before any caller tree is checked out unless the caller's `release` environment really carries a required reviewer and a default-branch-only deployment policy. **One approval per release, on the half that cannot be undone**, rather than one per merge. The floor gate then reads which arm the caller asked for and resolves which tool will perform *this* caller's publish, refusing before anything is packed in the job that publishes if that tool cannot be identified or cannot make the OIDC exchange, and printing the tool and its version; on the staged arm it also refuses if that tool is below staging's floor or if the registry will not say whether the package exists. By default a release is **live when its run ends**, with nobody asked to promote it. With `publish-mode: staged`, a version of an existing package is **staged rather than published**, so a maintainer inspects the exact tarball and approves it with 2FA before any consumer can resolve it; an unapproved stage leaves the live registry unchanged. On failure, uploads the redacted npm debug log as a run artifact. The docs dispatch **warns rather than failing the run**, because it happens after the publish is permanent and the artifact itself is fine. The install gate is the one post-publish check that **can** fail the run, because a positive finding means the artifact is not fine | every published parser | | [`nightly-fuzz.yml`](.github/workflows/nightly-fuzz.yml) | run the fuzz target; malformed bytes must never crash/hang/OOM | byte parsers (`dicom`, `mllp`) | | [`drift-check.yml`](.github/workflows/drift-check.yml) | fail when a repo diverges from `config/drift-manifest.json` | no repository calls it today | @@ -298,6 +298,94 @@ It does not move a single caller. Thirteen `uses:` lines live in thirteen other this repository has no way to edit them and no policy that can force them. Until each one moves, the `@main` fan-out is exactly what it was, and this is an offer rather than an enforcement. +## Every system and runtime + +`ci.yml`'s `verify` job runs on every entry of the `os` input, and two opt-in jobs run the caller's +test script under Bun and under Deno. A caller that passes nothing gets what it got before: `verify` +on `ubuntu-latest`, and `bun` and `deno` skipped. + +```yaml +jobs: + ci: + uses: cosyte/.github/.github/workflows/ci.yml@workflows-YYYY-MM-DD-COMMIT + with: + os: '["ubuntu-latest", "windows-latest", "macos-latest"]' + run-bun: true # adds the `ci / bun` check + run-deno: true # adds the `ci / deno` check +``` + +| Input | Default | What it does | +|---|---|---| +| `os` | `'["ubuntu-latest"]'` | the systems `verify` runs on, one leg per system and Node line | +| `run-bun` | `false` | the `bun` job: install with pnpm from the lockfile, then `bun --bun run test` | +| `bun-version` | `"1.4.4"` | the Bun the `bun` job installs | +| `run-deno` | `false` | the `deno` job: install with pnpm from the lockfile, take `node` off PATH, then `deno task test` | +| `deno-version` | `"2.9.8"` | the Deno the `deno` job installs | +| `working-directory` | `"."` | where `verify`, `bun` and `deno` find `package.json`; `prepublish` and `actionlint` ignore it | + +Both runtime jobs run on `ubuntu-latest` only, so each is one stable check context. A skipped job +satisfies a required context, so require `ci / bun` or `ci / deno` only while its input is on +([What a skipped required context does to a merge](#what-a-skipped-required-context-does-to-a-merge)). + +### How each runtime is made to run the suite + +Both jobs run the caller's own `test` script, so a library opts in without writing a second one. +What decides the runtime is how the script's `node` is started, and we measured it with a test that +throws `process.versions` from inside a vitest worker: + +- `bun run test` starts a `#!/usr/bin/env node` bin (vitest, tsx, a `cosyte-process` verb) with + Node. `bun --bun run test` starts it with Bun, so the job uses `--bun`. +- `deno task test` starts a package bin with Deno, but a script's literal `node` with the real Node + whenever one is on PATH, and with Deno only when none is. So the `deno` step replaces each PATH + entry that holds a `node` with a directory of links to everything else in it, and refuses to run + the suite while any `node` is still reachable. On `ubuntu-latest` that is two entries: the Node + `setup-node` installed and `/usr/local/bin`. + +Installing stays with pnpm and the committed lockfile in both jobs, so the suite runs against the +exact dependency tree `verify` tested. + +### The proof: `self-check.yml` runs `ci.yml` itself on every system + +This repository is not a package, so its `systems` job runs `ci.yml` against +[`test/fixtures/ci-caller/`](test/fixtures/ci-caller), with `os` set to all three systems, every +default Node line, and both runtime jobs on. Every script of the fixture fails on the hazard it +stands for, and `test/ci-caller-fixture.test.mjs` shows each one failing: + +| Script | Fails when | +|---|---| +| `format:check` | any file holds a carriage return, which is what a CRLF checkout on Windows produces | +| `lint` | the dash checker callers run (`cosyte-no-emdash`) does not pass the fixture's text, or does not find a planted em dash | +| `phi-scan` | the PHI scanner callers run (`cosyte-phi-scan`) does not pass the fixture, or does not find a planted, synthetic SSN shape | +| `test` | the system is not the one `RUNNER_OS` names, or the source module does not load; it prints the runtime | +| `build`, `attw` | `dist/` is not written, or an export target has no file, before the shared ESM and CJS smoke step loads it | + +So a green `systems` run shows, on each system: every shared step of `verify` (checkout, pnpm, +`setup-node` and its cache, the docs-content delivery and checker, the install, each script started +through the system's default shell, the ESM and CJS smoke step), and the real dash and PHI checkers +from `@cosyte/script-utils` passing clean input and finding planted input. + +What it found, on 2026-11-10: + +- **macOS cannot run the dash checker.** `cosyte-no-emdash` needs GNU grep's `-P`, and the macOS + runner's BSD grep has none: the checker refuses every input with "the scanner cannot match a known + em dash", which is it failing closed, as it must. The fixture's `lint` requires that refusal on + macOS and turns red the day the checker passes there, so the expectation cannot outlive the fix. + Callers run the dash checker in the `gate-no-emdash` workflows, which run on `ubuntu-latest`, so + no caller's `verify` is affected unless its own `lint` runs the checker. +- **Windows checks out LF.** `actions/checkout` on `windows-latest` wrote every fixture file with LF + line endings, so a caller's formatter sees the same bytes it sees on Linux. The fixture's + `format:check` is the tripwire if a runner image ever changes that. +- **The PHI scanner and the dash checker run on Windows**, where the dash checker starts `bash` and + finds Git's. + +What it cannot show: that a caller's own tools work on another system. The fixture's scripts are +small stand-ins for vitest, eslint, prettier, tsup, attw and a repository's own PHI scanner. A +caller learns that the first time its own `os` list runs on its own tree. Likewise the runtime jobs +prove that the suite is started under Bun or Deno, not that a caller's suite passes there: on +2026-11-10, `hl7`'s suite (4,114 tests) passed under Deno 2.9.8 and failed one test under Bun 1.4.4: +a test whose premise is that a file copied outside the package cannot resolve an installed +dependency, which under Bun it can. + ## The docs-content gate **`check-docs-content` is OFF by default, and no caller changes behavior until it opts in by diff --git a/test/ci-caller-fixture.test.mjs b/test/ci-caller-fixture.test.mjs index ed8354f..8aafb4c 100644 --- a/test/ci-caller-fixture.test.mjs +++ b/test/ci-caller-fixture.test.mjs @@ -15,7 +15,10 @@ import { fileURLToPath } from 'node:url'; import { PLANTED, + REFUSES, + RUNS, carriageReturns, + dashExpectation, filesUnder, judge, missingTargets, @@ -54,14 +57,15 @@ async function verb(name, root, overrides = {}) { /** * A directory of stub checkers. The dash stub fails on an em dash in its stdin; the PHI stub fails on a - * dashed SSN shape in any file it is named. `brokenDash` and `brokenPhi` make each one pass everything. + * dashed SSN shape in any file it is named. `brokenDash` and `brokenPhi` make each one pass everything, + * and `refusingDash` makes the dash stub fail everything, as the real one does on macOS. */ -function stubs({ brokenDash = false, brokenPhi = false } = {}) { +function stubs({ brokenDash = false, refusingDash = false, brokenPhi = false } = {}) { const dir = mkdtempSync(join(tmpdir(), 'ci-caller-bin-')); writeFileSync( join(dir, 'cosyte-no-emdash.js'), - brokenDash - ? 'process.exit(0);\n' + brokenDash || refusingDash + ? `process.exit(${refusingDash ? 1 : 0});\n` : 'let t = "";\nprocess.stdin.on("data", (c) => (t += c)).on("end", () => process.exit(t.includes(String.fromCodePoint(0x2014)) ? 1 : 0));\n', ); writeFileSync( @@ -158,7 +162,7 @@ test('attw fails on each export target with no file, and passes when every one i assert.deepEqual(await verb('attw', root), { code: 0, lines: ['attw: every export target is on disk'] }); }); -test('judge wants 0 on the clean input and 1 on the planted one, and nothing else', () => { +test('judge wants 0 on the clean input and 1 on the planted one by default, and nothing else', () => { assert.deepEqual(judge('x', 0, 1), []); assert.deepEqual(judge('x', 1, 1), ['x exited 1 on the clean input, not 0']); assert.deepEqual(judge('x', 0, 0), ['x exited 0 on the planted input, not 1, so its clean verdict proves nothing']); @@ -168,6 +172,18 @@ test('judge wants 0 on the clean input and 1 on the planted one, and nothing els ]); }); +test('judge holds a checker that cannot run to refusing every input', () => { + assert.deepEqual(judge('x', 1, 1, REFUSES), []); + assert.deepEqual(judge('x', 0, 1, REFUSES), ['x exited 0 on the clean input, not 1']); + assert.deepEqual(judge('x', 1, 0, REFUSES), ['x exited 0 on the planted input, not 1, so its clean verdict proves nothing']); +}); + +test('the dash checker must refuse on macOS and run everywhere else', () => { + assert.equal(dashExpectation('darwin'), REFUSES); + assert.equal(dashExpectation('linux'), RUNS); + assert.equal(dashExpectation('win32'), RUNS); +}); + test('lint passes clean text and finds the planted dash, and fails if either answer is wrong', async () => { const clean = tree({ 'README.md': 'a plain - hyphen\n' }); const ok = await verb('lint', clean, { binDir: stubs() }); @@ -184,6 +200,24 @@ test('lint passes clean text and finds the planted dash, and fails if either ans assert.equal(red.code, 1); assert.equal(red.lines.at(-1), 'lint: cosyte-no-emdash exited 1 on the clean input, not 0'); + // On macOS a checker that refuses everything is the expected answer, and one that runs is not. + const refusing = stubs({ refusingDash: true }); + assert.deepEqual(await verb('lint', clean, { binDir: refusing, platform: 'darwin' }), { + code: 0, + lines: [ + 'lint: the next run is fed a planted em dash, and must find it', + 'lint: on macOS the dash checker needs GNU grep (-P), which the runner lacks, so it must refuse every input', + 'lint: if it passes the clean input here, it now runs on macOS and this expectation is out of date', + 'lint: the dash checker refused on macOS rather than passing', + ], + }); + const runsOnMac = await verb('lint', clean, { binDir: stubs(), platform: 'darwin' }); + assert.equal(runsOnMac.code, 1); + assert.equal(runsOnMac.lines.at(-1), 'lint: cosyte-no-emdash exited 0 on the clean input, not 1'); + const refusesOnLinux = await verb('lint', clean, { binDir: refusing }); + assert.equal(refusesOnLinux.code, 1); + assert.equal(refusesOnLinux.lines.at(-1), 'lint: cosyte-no-emdash exited 1 on the clean input, not 0'); + const blind = await verb('lint', clean, { binDir: stubs({ brokenDash: true }) }); assert.equal(blind.code, 1); assert.equal(blind.lines.at(-1), 'lint: cosyte-no-emdash exited 0 on the planted input, not 1, so its clean verdict proves nothing'); diff --git a/test/ci-systems.test.mjs b/test/ci-systems.test.mjs index 55effa2..4188c99 100644 --- a/test/ci-systems.test.mjs +++ b/test/ci-systems.test.mjs @@ -1,6 +1,6 @@ -// `ci.yml` on every system and runtime: the line ending step `verify` runs first on Windows, the -// opt-in `bun` and `deno` jobs, the `working-directory` input that lets this repository run the -// workflow against its own fixture, and the `systems` job in `self-check.yml` that does it. +// `ci.yml` on every system and runtime: the opt-in `bun` and `deno` jobs, the shells `verify` names +// so a Windows leg runs the same scripts, the `working-directory` input that lets this repository +// run the workflow against its own fixture, and the `systems` job in `self-check.yml` that does it. // // WHAT THIS FILE CAN PROVE AND WHAT IT CANNOT. It reads the two workflow files and runs the one shell // script whose logic is ours (the deno step's PATH rewrite) against stubs. It cannot prove that a step diff --git a/test/fixtures/ci-caller/tool.mjs b/test/fixtures/ci-caller/tool.mjs index 074b5b5..2660a24 100644 --- a/test/fixtures/ci-caller/tool.mjs +++ b/test/fixtures/ci-caller/tool.mjs @@ -8,7 +8,10 @@ // format-check a carriage return in any file: a Windows checkout that turned LF into CRLF, which // every caller's formatter reports as a whole tree of unformatted files // lint the dash checker callers run (`cosyte-no-emdash` from `@cosyte/script-utils`) -// failing to pass the fixture's own text, or failing to find a planted em dash +// failing to pass the fixture's own text, or failing to find a planted em dash. On +// macOS it cannot run: it needs GNU grep's `-P`, the runner's BSD grep has none, and +// the checker refuses every input rather than reporting a clean one. So on macOS +// the verb requires that refusal, and turns red the day the checker passes there // phi-scan the PHI scanner callers run (`cosyte-phi-scan`) failing to pass the fixture, or // failing to find a planted, synthetic SSN shape // test a run on a system other than the one the runner says it is, and a source module @@ -81,16 +84,27 @@ export function runChecker(binDir, root, name, args, input) { return result.status; } +/** What a checker that runs answers: 0 on the clean input, 1 on the planted one. */ +export const RUNS = { clean: 0, planted: 1 }; + +/** What a checker that cannot run answers when it fails closed: 1 on every input. */ +export const REFUSES = { clean: 1, planted: 1 }; + +/** What the dash checker must answer on `platform`: it refuses on macOS, which has no GNU grep. */ +export function dashExpectation(platform) { + return platform === "darwin" ? REFUSES : RUNS; +} + /** - * Judge a checker by two runs: the clean input must pass (0) and the planted one must be found (1). + * Judge a checker by two runs against what it must answer: `expected`, by default `RUNS`. * * @returns The problems, empty when both runs answered as they must. */ -export function judge(name, cleanStatus, plantedStatus) { +export function judge(name, cleanStatus, plantedStatus, expected = RUNS) { const problems = []; - if (cleanStatus !== 0) problems.push(`${name} exited ${cleanStatus} on the clean input, not 0`); - if (plantedStatus !== 1) { - problems.push(`${name} exited ${plantedStatus} on the planted input, not 1, so its clean verdict proves nothing`); + if (cleanStatus !== expected.clean) problems.push(`${name} exited ${cleanStatus} on the clean input, not ${expected.clean}`); + if (plantedStatus !== expected.planted) { + problems.push(`${name} exited ${plantedStatus} on the planted input, not ${expected.planted}, so its clean verdict proves nothing`); } return problems; } @@ -126,7 +140,13 @@ export async function run(verb, context) { log("lint: the next run is fed a planted em dash, and must find it"); const dash = String.fromCodePoint(0x2014); const planted = runChecker(binDir, root, "cosyte-no-emdash", ["--stdin", "a planted dash"], `a ${dash} b\n`); - return report(verb, judge("cosyte-no-emdash", clean, planted), "the dash checker passed clean text and found a planted dash", log); + const expected = dashExpectation(platform); + if (expected === REFUSES) { + log("lint: on macOS the dash checker needs GNU grep (-P), which the runner lacks, so it must refuse every input"); + log("lint: if it passes the clean input here, it now runs on macOS and this expectation is out of date"); + } + const passed = expected === REFUSES ? "the dash checker refused on macOS rather than passing" : "the dash checker passed clean text and found a planted dash"; + return report(verb, judge("cosyte-no-emdash", clean, planted, expected), passed, log); } if (verb === "phi-scan") { const clean = runChecker(binDir, root, "cosyte-phi-scan", []); From 9877c135af5ff6c851a20a53bea3268d03b13a99 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Mon, 9 Nov 2026 23:31:27 -0500 Subject: [PATCH 3/6] fix(docs-content): run the checker on Windows, where its entry guard never matched The checker decided whether to run main by comparing resolve(argv[1]) with resolve(new URL(import.meta.url).pathname). On a Windows runner that pathname is /D:/a/_temp/docs-content-check.mjs, which names no path argv[1] can, so main never ran and the docs-content step exited 0 having checked nothing. The systems run's Windows legs printed no summary line, which is how it showed. The guard now compares file URLs. Claude-Session: https://claude.ai/code/session_016orFiR8mq7oCYo77QDTw2U --- CHANGELOG.md | 5 +++ README.md | 6 ++++ scripts/docs-content-check.mjs | 22 ++++++++++++- test/docs-content-entry.test.mjs | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 test/docs-content-entry.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 045f4ef..6bcdd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ so the next change to this file moves those lines under a heading with that name `ci.yml` itself against `test/fixtures/ci-caller/` (README.md, "Every system and runtime"). One limit it found: the dash checker in `@cosyte/script-utils` cannot run on macOS, where grep has no `-P`; it refuses rather than passing, so a caller whose own `lint` runs it reds on macOS. +- `scripts/docs-content-check.mjs`, which `verify` fetches when `check-docs-content` is on, now runs on + a Windows runner. Its entry guard compared its own URL's pathname with `argv[1]` as paths, which on + Windows never matched, so the step exited 0 without checking anything. It compares file URLs now. + A caller does nothing: no caller runs `verify` on Windows yet, and on Linux and macOS the checker + runs as before. - `ci.yml` takes a `working-directory` input, `"."` by default, naming where `verify`, `bun` and `deno` find `package.json`. It exists so this repository can run the workflow against its fixture; at `"."` every step reads what it read before. `prepublish` and `actionlint` ignore it. diff --git a/README.md b/README.md index 9bb3125..3dc4844 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,12 @@ What it found, on 2026-11-10: - **Windows checks out LF.** `actions/checkout` on `windows-latest` wrote every fixture file with LF line endings, so a caller's formatter sees the same bytes it sees on Linux. The fixture's `format:check` is the tripwire if a runner image ever changes that. +- **The docs-content checker checked nothing on Windows.** Its entry guard compared paths, and on a + Windows runner the path it read from its own URL was `/D:/a/_temp/docs-content-check.mjs`, which + matched nothing, so `main` never ran and the step exited 0. The Windows legs gave it away by + printing no "Checked N file(s)" line. The guard now compares file URLs + (`test/docs-content-entry.test.mjs` asks it the Windows question with Node's win32 rules), and the + Windows legs print the summary. No caller was affected: none runs `verify` on Windows yet. - **The PHI scanner and the dash checker run on Windows**, where the dash checker starts `bash` and finds Git's. diff --git a/scripts/docs-content-check.mjs b/scripts/docs-content-check.mjs index f6e6788..ce5d6b0 100644 --- a/scripts/docs-content-check.mjs +++ b/scripts/docs-content-check.mjs @@ -65,6 +65,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { resolve } from 'node:path'; import process from 'node:process'; +import { pathToFileURL } from 'node:url'; /** The one directory this gate reads, at the caller's repository root. */ export const DOCS_ROOT = 'docs-content'; @@ -1754,6 +1755,25 @@ export function main(argv) { return 0; } -if (process.argv[1] && toPosix(resolve(process.argv[1])) === toPosix(resolve(new URL(import.meta.url).pathname))) { +/** + * Whether the file at `moduleUrl` is the script node was started with (`argv1`), compared as file URLs. + * + * As URLs, never as paths: this spelling used to compare `resolve(argv1)` with + * `resolve(new URL(moduleUrl).pathname)`, and on a Windows runner that pathname is + * `/D:/a/_temp/docs-content-check.mjs`, which resolves to no path `argv1` can name. So on Windows + * `main` never ran, and the step exited 0 having checked nothing: found by `self-check.yml`'s + * `systems` job, whose Windows legs printed no summary line. + * + * @param {string | undefined} argv1 + * @param {string} moduleUrl + * @param {(path: string) => string} [toUrl] how a path becomes a file URL, resolved against the working + * directory; the platform's own by default. No argument at all is the working directory, which is + * never a module, so it never matches. + */ +export function invokedDirectly(argv1, moduleUrl, toUrl = (path) => pathToFileURL(path).href) { + return toUrl(argv1 ?? '') === moduleUrl; +} + +if (invokedDirectly(process.argv[1], import.meta.url)) { process.exitCode = main(process.argv.slice(2)); } diff --git a/test/docs-content-entry.test.mjs b/test/docs-content-entry.test.mjs new file mode 100644 index 0000000..28e3249 --- /dev/null +++ b/test/docs-content-entry.test.mjs @@ -0,0 +1,55 @@ +// The docs-content checker's entry guard: the line that decides whether `node docs-content-check.mjs` +// runs `main` at all. On a Windows runner it did not. The guard compared `resolve(argv[1])` with +// `resolve(new URL(import.meta.url).pathname)`, that pathname is `/D:/a/_temp/docs-content-check.mjs` +// there, and the two never matched, so `ci.yml`'s docs-content step exited 0 having checked nothing. +// `self-check.yml`'s `systems` job found it: its Windows legs printed no summary line. +// +// This file runs on Linux, so it cannot start the checker on Windows. It asks the guard the +// question Windows asks, with Node's win32 path and URL rules, and keeps the old spelling beside it +// as the control that shows the question can fail. + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, win32 } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { invokedDirectly } from '../scripts/docs-content-check.mjs'; + +const CHECKER = fileURLToPath(new URL('../scripts/docs-content-check.mjs', import.meta.url)); + +/** Where `ci.yml` puts the checker on a Windows runner, as node sees it there. */ +const WINDOWS_ARGV1 = 'D:\\a\\_temp\\docs-content-check.mjs'; +const WINDOWS_URL = 'file:///D:/a/_temp/docs-content-check.mjs'; + +/** Path to file URL by Windows rules, which is what the default does on a Windows runner. */ +const windowsToUrl = (path) => pathToFileURL(win32.resolve(path), { windows: true }).href; + +test('on Windows the old guard never matched, which is why main never ran there', () => { + const old = win32.resolve(WINDOWS_ARGV1) === win32.resolve(new URL(WINDOWS_URL).pathname); + assert.equal(old, false); +}); + +test('the guard matches the script node was started with, by Windows rules and by POSIX rules', () => { + assert.equal(invokedDirectly(WINDOWS_ARGV1, WINDOWS_URL, windowsToUrl), true); + assert.equal(invokedDirectly('/home/runner/work/_temp/docs-content-check.mjs', 'file:///home/runner/work/_temp/docs-content-check.mjs'), true); +}); + +test('the guard refuses another script, and a missing one', () => { + assert.equal(invokedDirectly('D:\\a\\_temp\\other.mjs', WINDOWS_URL, windowsToUrl), false); + assert.equal(invokedDirectly('/tmp/other.mjs', pathToFileURL(CHECKER).href), false); + assert.equal(invokedDirectly(undefined, pathToFileURL(CHECKER).href), false); + assert.equal(invokedDirectly('', pathToFileURL(CHECKER).href), false); +}); + +test('started as a script, the checker runs main and prints its summary', () => { + const repo = mkdtempSync(join(tmpdir(), 'docs-entry-')); + mkdirSync(join(repo, 'docs-content')); + writeFileSync(join(repo, 'docs-content', 'intro.md'), '# Intro\n'); + writeFileSync(join(repo, 'docs-content', 'sidebars.json'), '{"docs":["intro"]}\n'); + const result = spawnSync(process.execPath, [resolve(CHECKER), '--repo', repo], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Checked 1 file\(s\), 0 link\(s\), 0 image\(s\) and 1 document id\(s\)/m); +}); From 55153e01ea2dbfacf56a1bc3bc9d94bbf3b51612 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Tue, 10 Nov 2026 00:13:05 -0500 Subject: [PATCH 4/6] test(ci): pin the runtime jobs' setup steps and run the bun step against a stub Mutation testing over the lines this branch adds to ci.yml found deletions no test noticed: the input descriptions, the runtime jobs' persist-credentials and cache settings, the bun step's version line, and the deno step's cleanup of a previous run. Each is now asserted, and the bun step is run against a stub as the deno step already was. The deno step's `export PATH` is dropped: PATH is always exported. Claude-Session: https://claude.ai/code/session_016orFiR8mq7oCYo77QDTw2U --- .github/workflows/ci.yml | 1 - test/ci-systems.test.mjs | 81 +++++++++++++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 043bdaf..af712b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -554,7 +554,6 @@ jobs: kept+=("${entry}") done PATH="$(IFS=':'; echo "${kept[*]}")" - export PATH if found="$(command -v node)"; then echo "::error::a node is still on PATH at ${found}, so the suite would run under Node rather than Deno" exit 1 diff --git a/test/ci-systems.test.mjs b/test/ci-systems.test.mjs index 4188c99..c955a09 100644 --- a/test/ci-systems.test.mjs +++ b/test/ci-systems.test.mjs @@ -54,11 +54,11 @@ function inputNames() { .map((line) => line.trim().slice(0, -1)); } -/** One input's `type`, `default` and `required`, as written. */ +/** One input's `description`, `type`, `default` and `required`, as written. */ function inputOf(name) { const body = block(CI, ` ${name}:`); const value = (key) => body.find((line) => line.startsWith(` ${key}: `))?.slice(10 + key.length); - return { type: value('type'), default: value('default'), required: value('required') }; + return { description: value('description'), type: value('type'), default: value('default'), required: value('required') }; } /** A job's steps, one string each, with the step's own lines joined. */ @@ -111,11 +111,17 @@ test('the new inputs come after every existing one, and each is off or inert by ]); // THE DEFAULTS ARE WHAT KEEP A MERGE FROM CHANGING ANY CALLER: both runtimes off, and the // directory at the repository root, where every step reads what it read before. - assert.deepEqual(inputOf('working-directory'), { type: 'string', default: '"."', required: undefined }); - assert.deepEqual(inputOf('run-bun'), { type: 'boolean', default: 'false', required: undefined }); - assert.deepEqual(inputOf('run-deno'), { type: 'boolean', default: 'false', required: undefined }); - assert.deepEqual(inputOf('bun-version'), { type: 'string', default: '"1.4.4"', required: undefined }); - assert.deepEqual(inputOf('deno-version'), { type: 'string', default: '"2.9.8"', required: undefined }); + // The description is what a caller reads on the reference's page, so each one says what it does. + assert.deepEqual(inputOf('working-directory'), { + description: '"The directory holding package.json, relative to the repository root. verify, bun and deno only."', + type: 'string', + default: '"."', + required: undefined, + }); + assert.deepEqual(inputOf('run-bun'), { description: '"Run the test script under Bun, in the bun job."', type: 'boolean', default: 'false', required: undefined }); + assert.deepEqual(inputOf('run-deno'), { description: '"Run the test script under Deno, in the deno job."', type: 'boolean', default: 'false', required: undefined }); + assert.deepEqual(inputOf('bun-version'), { description: '"The Bun version the bun job installs."', type: 'string', default: '"1.4.4"', required: undefined }); + assert.deepEqual(inputOf('deno-version'), { description: '"The Deno version the deno job installs."', type: 'string', default: '"2.9.8"', required: undefined }); }); test('bun and deno are added after the existing jobs, each behind its own input, and nothing depends on them', () => { @@ -143,8 +149,21 @@ test('each runtime job installs what verify installs, then runs the caller\'s ow assert.ok(list.some((step) => step.includes('node-version: ${{ fromJSON(inputs.node-versions)[0] }}')), `${name} installs on the lowest Node line`); assert.ok(list.every((step) => !step.includes('continue-on-error')), `${name} swallows no failure`); } + // The first three steps of each runtime job, exactly: a checkout that keeps no credential for the + // caller's suite to find, pnpm from the package's own manifest, and Node with verify's cache. + for (const [name, list] of [['bun', bun], ['deno', deno]]) { + assert.deepEqual(list.slice(0, 3), [ + ' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n with:\n persist-credentials: false', + " - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9\n with:\n package_json_file: ${{ inputs.working-directory == '.' && 'package.json' || format('{0}/package.json', inputs.working-directory) }}", + " - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0\n with:\n node-version: ${{ fromJSON(inputs.node-versions)[0] }}\n cache: pnpm\n cache-dependency-path: ${{ inputs.working-directory != '.' && format('{0}/pnpm-lock.yaml', inputs.working-directory) || '' }}", + ], `${name}'s setup steps`); + } assert.ok(bun.some((step) => /oven-sh\/setup-bun@[0-9a-f]{40} # v2\.2\.0\n {8}with:\n {10}bun-version: \$\{\{ inputs\.bun-version \}\}/.test(step))); assert.ok(deno.some((step) => /denoland\/setup-deno@[0-9a-f]{40} # v2\.0\.5\n {8}with:\n {10}deno-version: \$\{\{ inputs\.deno-version \}\}/.test(step))); + // Both scripts stop at the first failure and on an unset variable, beyond the `-e -o pipefail` + // the runner's own bash already has. + assert.match(runScript('bun', 'The test suite under Bun'), /^set -euo pipefail\n/); + assert.match(runScript('deno', 'The test suite under Deno'), /^set -euo pipefail\n/); // `--bun` is the flag that moves a script's `node` onto Bun; `bun run test` alone runs it on Node. assert.match(runScript('bun', 'The test suite under Bun'), /^bun --bun run test$/m); assert.match(runScript('deno', 'The test suite under Deno'), /^deno task test$/m); @@ -194,17 +213,17 @@ function executable(path, body) { * Run the deno step's script with PATH = `extra` dirs + a dir of real tools + a dir with a stub deno. * The stub records what `node` and `helper` resolve to when it runs, and its arguments. */ -function runDenoStep({ withNodeDir = true, emptyEntry = false, denoExit = 0 } = {}) { - const root = mkdtempSync(join(tmpdir(), 'ci-systems-')); +function runDenoStep({ withNodeDir = true, emptyEntry = false, denoExit = 0, root: reuse, temp: reuseTemp, env = {} } = {}) { + const root = reuse ?? mkdtempSync(join(tmpdir(), 'ci-systems-')); const tools = join(root, 'tools'); const withNode = join(root, 'with-node'); const denoDir = join(root, 'deno-bin'); const cwd = join(root, 'cwd'); - const temp = join(root, 'runner-temp'); - for (const dir of [tools, withNode, denoDir, cwd, temp]) mkdirSync(dir); + const temp = reuseTemp ?? join(root, 'runner-temp'); + for (const dir of [tools, withNode, denoDir, cwd, temp]) mkdirSync(dir, { recursive: true }); for (const tool of ['rm', 'mkdir', 'ln', 'head', 'cat', 'sh']) { const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim(); - symlinkSync(found, join(tools, tool)); + if (!existsSync(join(tools, tool))) symlinkSync(found, join(tools, tool)); } executable(join(withNode, 'node'), 'echo real-node'); executable(join(withNode, 'helper'), 'echo helper'); @@ -216,6 +235,7 @@ function runDenoStep({ withNodeDir = true, emptyEntry = false, denoExit = 0 } = `echo "args=$*" >> '${record}'`, `echo "node=$(command -v node || echo none)" >> '${record}'`, `echo "helper=$(command -v helper || echo none)" >> '${record}'`, + `echo "$PATH" > '${record}.path'`, 'if [ "$1" = "--version" ]; then echo "deno 0.0.0-stub"; exit 0; fi', `exit ${denoExit}`, ].join('\n'), @@ -224,11 +244,12 @@ function runDenoStep({ withNodeDir = true, emptyEntry = false, denoExit = 0 } = const bash = spawnSync('sh', ['-c', 'command -v bash'], { encoding: 'utf8' }).stdout.trim(); const result = spawnSync(bash, ['-c', runScript('deno', 'The test suite under Deno')], { cwd, - env: { PATH: entries.join(':'), RUNNER_TEMP: temp }, + env: { ...env, PATH: entries.join(':'), RUNNER_TEMP: temp }, encoding: 'utf8', }); const recorded = existsSync(record) ? readFileSync(record, 'utf8') : ''; - return { ...result, recorded, temp, withNode }; + const path = existsSync(`${record}.path`) ? readFileSync(`${record}.path`, 'utf8').trim() : ''; + return { ...result, recorded, path, root, temp, tools, denoDir, withNode }; } test('the deno step takes node off PATH, keeps everything beside it, and runs the suite with deno task', () => { @@ -273,6 +294,38 @@ test('the deno step fails when the suite fails', () => { assert.match(run.recorded, /^args=task test$/m); }); +test('the deno step can run twice in one RUNNER_TEMP, replacing what the first run left', () => { + const first = runDenoStep(); + assert.equal(first.status, 0, first.stderr); + const second = runDenoStep({ temp: first.temp, root: first.root }); + assert.equal(second.status, 0, second.stderr); +}); + +test('the deno step builds PATH from PATH alone, never from a variable it inherits', () => { + const run = runDenoStep({ env: { kept: '/inherited/bin' } }); + assert.equal(run.status, 0, run.stderr); + assert.equal(run.path, [join(run.temp, 'path-without-node', '1'), run.tools, run.denoDir].join(':')); +}); + +test('the bun step prints the Bun it runs, runs the test script with --bun, and fails when it fails', () => { + for (const exit of [0, 4]) { + const root = mkdtempSync(join(tmpdir(), 'ci-systems-bun-')); + const record = join(root, 'record'); + executable( + join(root, 'bun'), + [`echo "args=$*" >> '${record}'`, 'if [ "$1" = "--version" ]; then echo 0.0.0-stub; exit 0; fi', `exit ${exit}`].join('\n'), + ); + const bash = spawnSync('sh', ['-c', 'command -v bash'], { encoding: 'utf8' }).stdout.trim(); + const result = spawnSync(bash, ['-c', runScript('bun', 'The test suite under Bun')], { + env: { PATH: `${root}:${dirname(bash)}` }, + encoding: 'utf8', + }); + assert.equal(result.status, exit, result.stderr); + assert.match(result.stdout, /^bun: 0\.0\.0-stub$/m); + assert.equal(readFileSync(record, 'utf8'), 'args=--version\nargs=--bun run test\n'); + } +}); + // --------------------------------------------------------------------------- // THE PROOF: self-check.yml RUNS ci.yml ON EVERY SYSTEM AND RUNTIME // --------------------------------------------------------------------------- From 8f2d4af8ef3d202d97964a3ed3c603b854110d23 Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Tue, 10 Nov 2026 01:49:11 -0500 Subject: [PATCH 5/6] test(ci): let the fixture's line endings follow the checkout The root .gitattributes says `* text=auto eol=lf`, and an eol attribute overrides core.autocrlf, so every checkout wrote the fixture with LF on every system and its format:check could never see a CRLF checkout. The fixture's own .gitattributes leaves eol unspecified, so the checkout's git settings decide, and a Windows leg now measures them. Claude-Session: https://claude.ai/code/session_016orFiR8mq7oCYo77QDTw2U --- test/ci-caller-fixture.test.mjs | 21 +++++++++++++++++++++ test/fixtures/ci-caller/.gitattributes | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 test/fixtures/ci-caller/.gitattributes diff --git a/test/ci-caller-fixture.test.mjs b/test/ci-caller-fixture.test.mjs index 8aafb4c..9ba9ba2 100644 --- a/test/ci-caller-fixture.test.mjs +++ b/test/ci-caller-fixture.test.mjs @@ -109,6 +109,27 @@ test('format-check fails on a carriage return, naming the file, and passes LF on assert.deepEqual(await verb('format-check', lf), { code: 0, lines: ['format-check: 2 files, LF line endings only'] }); }); +test('the fixture\'s line endings are decided by the checkout, not by this repository\'s eol=lf', () => { + // This repository's root `.gitattributes` is `* text=auto eol=lf`, and an `eol` attribute + // overrides `core.autocrlf` and `core.eol`, so under it every checkout on every system writes LF + // and `format-check` could never see a CRLF checkout. The fixture's own `.gitattributes` leaves + // `eol` unspecified, so its files are `text=auto` and the checkout's git settings decide: on a + // Windows leg, `format-check` then measures what `verify` set before it checked out. + const listed = spawnSync('git', ['ls-files', '-z'], { cwd: FIXTURE, encoding: 'utf8' }); + assert.equal(listed.status, 0, listed.stderr); + const files = listed.stdout.split('\0').filter(Boolean); + assert.ok(files.includes('tool.mjs') && files.includes('pnpm-lock.yaml'), files.join(', ')); + const attrs = spawnSync('git', ['check-attr', '-z', 'text', 'eol', '--', ...files], { cwd: FIXTURE, encoding: 'utf8' }); + assert.equal(attrs.status, 0, attrs.stderr); + const fields = attrs.stdout.split('\0'); + const seen = {}; + for (let i = 0; i + 2 < fields.length; i += 3) seen[`${fields[i]} ${fields[i + 1]}`] = fields[i + 2]; + for (const file of files) { + assert.equal(seen[`${file} text`], 'auto', `${file} is not text=auto`); + assert.equal(seen[`${file} eol`], 'unspecified', `${file} has its eol fixed, so no checkout can make it CRLF`); + } +}); + test('runtimeOf names Bun, then Deno, then Node', () => { assert.equal(runtimeOf({ bun: '1.4.4', node: '24.3.0' }), 'bun 1.4.4'); assert.equal(runtimeOf({ deno: '2.9.8', node: '24.2.0' }), 'deno 2.9.8'); diff --git a/test/fixtures/ci-caller/.gitattributes b/test/fixtures/ci-caller/.gitattributes new file mode 100644 index 0000000..14a6f5b --- /dev/null +++ b/test/fixtures/ci-caller/.gitattributes @@ -0,0 +1,3 @@ +# The root's `eol=lf` would fix every file here to LF on every checkout, and then `format-check` +# could never see what a Windows checkout writes. Unset here, so the checkout's settings decide. +* text=auto !eol From 80d8a269cec1ec0ac01f5681a90dcf5d86953d1c Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Tue, 10 Nov 2026 01:54:25 -0500 Subject: [PATCH 6/6] fix(ci): check text files out with LF line endings on every system With the fixture's eol left unspecified, windows-latest (image windows-2025-vs2026, git 2.56.0.windows.4) checked all eleven of its files out with CRLF and format:check failed on Node 22, 24 and 26, while ubuntu and macOS checked them out LF. verify's first step now sets core.autocrlf false and core.eol lf before the checkout, so the runner image does not decide. A file whose eol a repository's .gitattributes sets keeps it, since the attribute overrides both settings. The earlier LF result came from this repository's root eol=lf, not from the runner; README.md and CHANGELOG.md now say so. Claude-Session: https://claude.ai/code/session_016orFiR8mq7oCYo77QDTw2U --- .github/workflows/ci.yml | 17 ++++++++++++ CHANGELOG.md | 8 ++++++ README.md | 15 ++++++++--- test/ci-systems.test.mjs | 56 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af712b8..9f60a3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,23 @@ jobs: run: working-directory: ${{ inputs.working-directory }} steps: + # LF LINE ENDINGS ON EVERY SYSTEM, SET BEFORE THE CHECKOUT, SO THE RUNNER IMAGE DOES NOT DECIDE. + # Git on `windows-latest` checks text files out with CRLF: on 2026-11-10 (image + # windows-2025-vs2026, git 2.56.0.windows.4) every file of the fixture under + # `test/fixtures/ci-caller/`, whose eol is left unspecified, came out CRLF, and its + # `format:check` failed on all eleven. A caller whose `.gitattributes` says `eol=lf` never saw + # it, because an eol attribute overrides both settings below; a caller without one would have + # seen its formatter and its tests read different bytes on Windows than on Linux. These two + # make a file with no eol attribute come out as committed, LF for text, on every system, and + # leave a file whose eol the repository sets to that setting. No `if:`, so all three systems + # run the same step; in the workspace, because the directory `working-directory` names does + # not exist before the checkout. + - name: Check out text files with LF line endings + shell: bash + working-directory: . + run: | + git config --global core.autocrlf false + git config --global core.eol lf - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # At the default "." these two read exactly what they read before `working-directory` existed: # `package.json` is the action's own default, and an empty `cache-dependency-path` is unset. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d3b5ae..9e2988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,14 @@ so the next change to this file moves those lines under a heading with that name `ci.yml` itself against `test/fixtures/ci-caller/` (README.md, "Every system and runtime"). One limit it found: the dash checker in `@cosyte/script-utils` cannot run on macOS, where grep has no `-P`; it refuses rather than passing, so a caller whose own `lint` runs it reds on macOS. +- `verify` has a new first step, before the checkout, on every system: it sets git's + `core.autocrlf false` and `core.eol lf`, so text files check out with LF line endings. Measured on + 2026-11-10: without it, `windows-latest` checks out with CRLF every text file whose eol no + `.gitattributes` sets. A caller does nothing. On `ubuntu-latest`, the only system a caller runs + today, and on `macos-latest`, the same run checked those files out LF without the step, so there + it changes no byte. A file whose eol the caller's `.gitattributes` sets keeps that eol, since the + attribute overrides both settings, so a caller with `* text=auto eol=lf` gets LF with or without + the step. - `scripts/docs-content-check.mjs`, which `verify` fetches when `check-docs-content` is on, now runs on a Windows runner. Its entry guard compared its own URL's pathname with `argv[1]` as paths, which on Windows never matched, so the step exited 0 without checking anything. It compares file URLs now. diff --git a/README.md b/README.md index 0107795..445059a 100644 --- a/README.md +++ b/README.md @@ -373,9 +373,18 @@ What it found, on 2026-11-10: macOS and turns red the day the checker passes there, so the expectation cannot outlive the fix. Callers run the dash checker in the `gate-no-emdash` workflows, which run on `ubuntu-latest`, so no caller's `verify` is affected unless its own `lint` runs the checker. -- **Windows checks out LF.** `actions/checkout` on `windows-latest` wrote every fixture file with LF - line endings, so a caller's formatter sees the same bytes it sees on Linux. The fixture's - `format:check` is the tripwire if a runner image ever changes that. +- **Windows checks out CRLF unless told otherwise, so `verify` tells it.** With the fixture's eol + left unspecified (its own `.gitattributes` says `* text=auto !eol`), `actions/checkout` on + `windows-latest` (image windows-2025-vs2026, git 2.56.0.windows.4) wrote all eleven of its files + with CRLF line endings, and `format:check` failed on each one on Node 22, 24 and 26, while the + `ubuntu-latest` and `macos-latest` legs of the same run checked them out LF. So `verify`'s + first step, before the checkout, sets `core.autocrlf false` and `core.eol lf` on every system: + with it, the same legs check the same files out LF and pass. A file whose eol a repository's + `.gitattributes` sets keeps that setting, since an eol attribute overrides both. That is also + why the first runs here saw LF: this repository's root `.gitattributes` says + `* text=auto eol=lf`, which fixed the fixture to LF on every checkout and hid the runner's own + behavior, and why the fixture now unsets it. A caller whose `.gitattributes` already says + `eol=lf` was never affected; one without it gets LF from the step. - **The docs-content checker checked nothing on Windows.** Its entry guard compared paths, and on a Windows runner the path it read from its own URL was `/D:/a/_temp/docs-content-check.mjs`, which matched nothing, so `main` never ran and the step exited 0. The Windows legs gave it away by diff --git a/test/ci-systems.test.mjs b/test/ci-systems.test.mjs index c955a09..cd56afb 100644 --- a/test/ci-systems.test.mjs +++ b/test/ci-systems.test.mjs @@ -181,6 +181,62 @@ test('every multi-line script in verify, bun and deno names bash, so Windows doe } }); +const LINE_ENDINGS = 'Check out text files with LF line endings'; + +test('verify sets LF line endings before it checks out, on every system, from the workspace', () => { + const [first, second] = steps(CI, 'verify'); + // FIRST, so the checkout reads it; with no `if:`, so every system gets the same bytes; and in the + // workspace, because the fixture directory `working-directory` names does not exist before checkout. + assert.equal(first, [ + ` - name: ${LINE_ENDINGS}`, + ' shell: bash', + ' working-directory: .', + ' run: |', + ' git config --global core.autocrlf false', + ' git config --global core.eol lf', + ].join('\n')); + assert.match(second, /^ {6}- uses: actions\/checkout@/); +}); + +test('the line-ending step turns a CRLF checkout into LF, and leaves a file whose eol is set alone', () => { + // A Windows image's git: `core.autocrlf true` in its system config, as the Git for Windows + // installer writes by default, and a native line ending of CRLF, stood in for by `core.eol crlf`. + const root = mkdtempSync(join(tmpdir(), 'ci-systems-eol-')); + const home = join(root, 'home'); + const source = join(root, 'source'); + mkdirSync(home); + mkdirSync(join(source, 'docs'), { recursive: true }); + writeFileSync(join(root, 'system.gitconfig'), '[core]\n\tautocrlf = true\n\teol = crlf\n'); + const env = { PATH: process.env.PATH, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), GIT_CONFIG_SYSTEM: join(root, 'system.gitconfig') }; + const git = (cwd, ...args) => { + const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' }); + assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`); + }; + // Three kinds of file: no attribute, `text=auto` with no eol, and an eol the repository fixed. + writeFileSync(join(source, '.gitattributes'), 'docs/*.md text=auto\n*.bat text eol=crlf\n'); + writeFileSync(join(source, 'plain.txt'), 'one\ntwo\n'); + writeFileSync(join(source, 'docs/page.md'), 'one\ntwo\n'); + writeFileSync(join(source, 'run.bat'), 'one\ntwo\n'); + git(source, 'init', '-q'); + git(source, '-c', 'core.autocrlf=false', 'add', '.'); + git(source, '-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-q', '-m', 'fixture'); + const checkout = (name) => { + git(root, 'clone', '-q', source, name); + return Object.fromEntries(['plain.txt', 'docs/page.md', 'run.bat'].map((file) => [file, readFileSync(join(root, name, file), 'utf8')])); + }; + const CRLF = 'one\r\ntwo\r\n'; + const LF = 'one\ntwo\n'; + + // The control: without the step, this git writes every text file with CRLF. + assert.deepEqual(checkout('before'), { 'plain.txt': CRLF, 'docs/page.md': CRLF, 'run.bat': CRLF }); + + const bash = spawnSync('sh', ['-c', 'command -v bash'], { encoding: 'utf8' }).stdout.trim(); + const step = spawnSync(bash, ['-c', runScript('verify', LINE_ENDINGS)], { cwd: root, env, encoding: 'utf8' }); + assert.equal(step.status, 0, step.stderr); + // After it: LF, except where the repository's own attributes ask for CRLF. + assert.deepEqual(checkout('after'), { 'plain.txt': LF, 'docs/page.md': LF, 'run.bat': CRLF }); +}); + test('at the default directory, every job reads the package where it read it before the input existed', () => { for (const job of ['verify', 'bun', 'deno']) { const body = block(CI, ` ${job}:`);