Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b8f1f4e
build: add the package, lint, test, mutation, release and CI tooling
Mearman Sep 29, 2026
2197434
feat: add typed section descriptors and the shared layout section
Mearman Sep 29, 2026
3f57b51
feat: resolve a tool's section from the unified file or a standalone …
Mearman Sep 29, 2026
be56a28
feat: report config sections that no installed tool owns, with a command
Mearman Sep 29, 2026
f388a6b
build: emit the command line entry as an ES module only
Mearman Sep 29, 2026
fb7245b
docs: describe authoring, resolution, section authoring and the docto…
Mearman Sep 29, 2026
3e692e9
ci: run the packaged-install check from a script that also runs the c…
Mearman Sep 29, 2026
f183635
docs: use a neutral tool in the authoring example
Mearman Sep 29, 2026
03bbb53
fix: refuse section descriptors that widen or collide at the type level
Mearman Sep 29, 2026
0515119
fix: fail on a config file that exports no default or a null default
Mearman Sep 29, 2026
6a16cb4
fix: fail when the directory to read config from does not exist
Mearman Sep 29, 2026
1b5e031
fix: namespace the standalone file and read .mts and .cts config files
Mearman Sep 29, 2026
5f84329
fix: accept doctor --help, fail on a missing command, and say when th…
Mearman Sep 29, 2026
8b42b21
fix: let the project's own manifest own sections and tolerate unknown…
Mearman Sep 29, 2026
1e0777f
feat: re-export ConfigValidationError and ConfigValidationIssue
Mearman Sep 29, 2026
537afd9
fix: declare the runtime dependencies as caret ranges
Mearman Sep 29, 2026
403b0a9
test: pin the zod version of the packaged-install check to the reposi…
Mearman Sep 29, 2026
4ece2aa
docs: state what the type checker verifies and what waits for the load
Mearman Sep 29, 2026
fb055d9
refactor: keep config values out of the layout messages and document …
Mearman Sep 29, 2026
7480922
test: stop the alias test re-evaluating the source, which hid the cov…
Mearman Sep 29, 2026
b02b499
fix: reject a malformed package.json in doctor instead of ignoring it
Mearman Sep 29, 2026
0cb0b09
test: kill the surviving mutants of the section, layout and command code
Mearman Sep 29, 2026
7c57cbe
ci: run mutation testing on manual dispatch only
Mearman Sep 29, 2026
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
200 changes: 200 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# Cancels a superseded run when a branch or PR gets pushed to again. Scoped away from main so a merge landing mid-release never kills the release job, which runs solely on a push to main.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

permissions:
contents: read

jobs:
determine-runner:
uses: ./.github/workflows/determine-runner.yml

commitlint:
name: Commitlint
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ Codex Security Review · Automatically triggered

P1 Badge Security: Keep pull requests off the self-hosted runner fleet

When fork PR workflows are approved or auto-run and the shared fleet is available, keep every pull-request job on a hosted runner. pull_request evaluates the PR-controlled workflow revision, so an attacker can change the repository-local determine-runner.yml or this runs-on expression to select the self-hosted fleet; the fork-aware action is mutable before scheduling. CI then checks out the PR and executes pnpm install and repository scripts, yielding arbitrary code execution on that host. The read-only token and hosted release job limit direct publication but do not protect the runner. Enforce hosted runners for PRs or an immutable gate plus runner-group restrictions.

Useful? React with 👍 / 👎.

timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
# Not wagoid/commitlint-github-action: it is a Docker container action, and the self-hosted fleet has no Docker daemon.
- run: pnpm install --frozen-lockfile
- name: Lint the commits of a pull request
if: github.event_name == 'pull_request'
run: pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- name: Lint the last commit of a push
if: github.event_name != 'pull_request'
run: pnpm exec commitlint --last

lint:
name: Lint
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- uses: actions/cache@v6
with:
path: .eslintcache
# eslint.config.ts and pnpm-lock.yaml both invalidate the cache: a rule change or a dependency bump can change lint results even for files whose own content did not change.
key: eslintcache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.ts') }}
restore-keys: eslintcache-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
# The check-only script: `pnpm lint` fixes in place, so it would exit 0 on a fixable violation that never reaches the commit.
- run: pnpm lint:check

typecheck:
name: Typecheck
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
- run: pnpm exec publint
- run: pnpm exec attw --pack

mutation:
name: Mutation
# Manual only: run it from the Actions tab (workflow_dispatch) when a change warrants a full mutation pass. It is not a required check and does not gate a release.
if: github.event_name == 'workflow_dispatch'
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
# Content-diff based (Stryker's IncrementalDiffer text-diffs stored against current source, never hash, mtime or git ref), so restoring from an unrelated prior run is always safe: a mismatch only means bigger diffs and more reruns, never a wrongly skipped mutant.
- name: Restore Stryker incremental result cache
uses: actions/cache/restore@v6
with:
path: reports/stryker-incremental.json
key: stryker-incremental-${{ github.run_id }}
restore-keys: |
stryker-incremental-
# Stryker only diffs mutated and test files, so a change outside that scope (config, fixture data, an env var) is invisible to the incremental differ. --force on push to main (never on a PR) rebuilds the incremental file from a known-clean baseline so that blind spot cannot compound.
- run: pnpm test:mutation${{ github.ref == 'refs/heads/main' && ' --force' || '' }}
- name: Save Stryker incremental result cache
if: always()
uses: actions/cache/save@v6
with:
path: reports/stryker-incremental.json
key: stryker-incremental-${{ github.run_id }}

# The packed tarball, not the source tree, is what consumers install: this proves the published files, exports map, command line entry and peer dependency work in a clean project on every Node line the package supports and on both cosmiconfig majors. The toolchain needs Node 22, so the package is built and packed on 22 and the consumer check runs on the Node under test.
package:
name: Package (Node ${{ matrix.node }}, cosmiconfig ${{ matrix.cosmiconfig }})
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- { node: '20', cosmiconfig: '9' }
- { node: '22', cosmiconfig: '9' }
- { node: '22', cosmiconfig: '10' }
- { node: '24', cosmiconfig: '9' }
- { node: '24', cosmiconfig: '10' }
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm pack --pack-destination "$RUNNER_TEMP/pack"
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
- name: Install the tarball into a scratch project and load sections through it, as ESM and as CommonJS
run: bash test/package/install-check.sh "$RUNNER_TEMP/pack" "${{ matrix.cosmiconfig }}" "$RUNNER_TEMP/scratch"

required-checks:
name: Required Checks
needs: [determine-runner, commitlint, lint, typecheck, package]
# Always runs, even if a dependency failed, so it reports that failure itself rather than being skipped: a required check that is silently absent blocks a PR indefinitely instead of failing it visibly.
if: always()
runs-on: ${{ fromJson(needs.determine-runner.outputs.selected-runner) }}
timeout-minutes: 5
steps:
- name: Fail if any required job failed or was cancelled
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "One or more required jobs did not succeed:"
echo '${{ toJSON(needs) }}'
exit 1
fi

release:
name: Release
needs: [commitlint, lint, typecheck, package]
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
# Deliberately a GitHub-hosted runner, not the resolved one: npm trusted publishing only works from GitHub-hosted runners, so the OIDC token exchange cannot happen on the self-hosted fleet or Blacksmith.
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # to push the release commit and tag and create the GitHub Release
issues: write # to comment on released issues
pull-requests: write # to comment on released pull requests
id-token: write # OIDC identity for npm trusted publishing and provenance (no NPM_TOKEN)
steps:
- uses: actions/checkout@v7
with:
# semantic-release analyses the full commit history since the last release.
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
# registry-url is deliberately absent: setup-node would otherwise write an .npmrc _authToken line that wins over the OIDC token exchange, breaking trusted publishing.
- run: pnpm install --frozen-lockfile
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
run: npm install -g npm@latest
- name: Release
# HUSKY=0 so the commit-msg hook never fires against the automated release commit.
run: HUSKY=0 pnpm exec semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Blanked, not omitted: an inherited NPM_TOKEN or NODE_AUTH_TOKEN would otherwise be used in preference to the OIDC exchange.
NPM_TOKEN: ''
NODE_AUTH_TOKEN: ''
31 changes: 31 additions & 0 deletions .github/workflows/determine-runner.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Determine runner

# The one runner-selection job every job in ci.yml waits on (via workflow_call) instead of hard-coding a runner: ExaDev's self-hosted fleet first, falling back to Blacksmith if the fleet's heartbeat is stale. The decision is made by ExaDev/runner-fallback-action, which reads a secret gist refreshed by the fleet's heartbeat (exadev/github-runner); no token, no secret, no per-repo setup. The action also forces the fallback for Dependabot and fork PRs so untrusted code never runs on the fleet.
#
# The resolver itself runs on Blacksmith's smallest runner, not ubuntu-latest and not the fleet. This job decides whether the fleet can be used at all, so it must sit on infrastructure independent of both: on the fleet, a fleet-wide outage deadlocks every workflow at this job because the fallback can never engage; on ubuntu-latest, the org's recurring GitHub-hosted billing block stops the job before it starts. Blacksmith is billed outside GitHub's spending limit.
#
# The fallback is Blacksmith rather than the action's ubuntu-latest default for the same reason: routing dependent jobs to a pool that cannot start would recreate the deadlock one hop out. The preferred label is the action's default (exadev-runners, the fleet's shared runs-on label), so no `preferred-labels` override is set and routing returns to the fleet whenever the heartbeat is current.
#
# The action is pinned to a commit SHA, not a branch: b774d51 is the commit that introduced the secret-gist design. Bump deliberately when a newer commit is wanted.
on:
workflow_call:
outputs:
selected-runner:
description: "A JSON array of runner labels for fromJson at the call site."
value: ${{ jobs.determine-runner.outputs.selected-runner }}
preferred-available:
description: "Whether the preferred (fleet) runner was reachable."
value: ${{ jobs.determine-runner.outputs.preferred-available }}

jobs:
determine-runner:
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
selected-runner: ${{ steps.fallback.outputs.selected-runner || '["blacksmith-2vcpu-ubuntu-2404"]' }}
preferred-available: ${{ steps.fallback.outputs.preferred-available || 'false' }}
steps:
- id: fallback
uses: ExaDev/runner-fallback-action@b774d51ff5360bf53705b2eb34ff019afd4d7f07
with:
fallback-labels: blacksmith-2vcpu-ubuntu-2404
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ coverage/
.turbo/
reports/
.stryker-tmp/

# The instruction files are tracked symlinks to README.md; a global ignore may otherwise exclude them.
!CLAUDE.md
!AGENTS.md
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx commitlint --edit "$1"
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx lint-staged
1 change: 1 addition & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx pnpm run typecheck && npx pnpm run test && npx pnpm run build
1 change: 1 addition & 0 deletions AGENTS.md
1 change: 1 addition & 0 deletions CLAUDE.md
Loading