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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
#
Expand Down Expand Up @@ -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, `<caller job id> / bun` and `<caller job id> / 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
21 changes: 21 additions & 0 deletions .github/workflows/self-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<node>, <os>)`. 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
Expand Down
Loading
Loading