diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 502ba2a..9f60a3d 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,38 @@ 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: + # 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. - 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 +475,105 @@ 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[*]}")" + 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/CHANGELOG.md b/CHANGELOG.md index e2f1f7e..9e2988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,39 @@ 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. +- `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. + 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. +- 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 aced884..445059a 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 | @@ -299,6 +299,109 @@ 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 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 + 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. + +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/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/ci-caller-fixture.test.mjs b/test/ci-caller-fixture.test.mjs new file mode 100644 index 0000000..9ba9ba2 --- /dev/null +++ b/test/ci-caller-fixture.test.mjs @@ -0,0 +1,300 @@ +// 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, + REFUSES, + RUNS, + carriageReturns, + dashExpectation, + 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, + * and `refusingDash` makes the dash stub fail everything, as the real one does on macOS. + */ +function stubs({ brokenDash = false, refusingDash = false, brokenPhi = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'ci-caller-bin-')); + writeFileSync( + join(dir, 'cosyte-no-emdash.js'), + 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( + 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('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'); + 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 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']); + 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('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() }); + 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'); + + // 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'); +}); + +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..cd56afb --- /dev/null +++ b/test/ci-systems.test.mjs @@ -0,0 +1,413 @@ +// `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 +// 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 `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 { 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. */ +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. + // 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', () => { + 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`); + } + // 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); +}); + +// --------------------------------------------------------------------------- +// 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()}`); + } + } +}); + +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}:`); + 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, 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 = 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(); + if (!existsSync(join(tools, tool))) 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}'`, + `echo "$PATH" > '${record}.path'`, + '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: { ...env, PATH: entries.join(':'), RUNNER_TEMP: temp }, + encoding: 'utf8', + }); + const recorded = existsSync(record) ? readFileSync(record, 'utf8') : ''; + 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', () => { + 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); +}); + +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 +// --------------------------------------------------------------------------- + +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/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); +}); 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 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..2660a24 --- /dev/null +++ b/test/fixtures/ci-caller/tool.mjs @@ -0,0 +1,193 @@ +// 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. 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 +// 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; +} + +/** 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 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, expected = RUNS) { + const problems = []; + 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; +} + +/** 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`); + 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", []); + 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(