workspace-conformance 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # workspace-conformance
2
2
 
3
- Conformance checks for a pnpm workspace that ESLint cannot express. Type-graph checks run on [ts-morph](https://ts-morph.com), import-graph checks run on [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) with rules generated from the shared `layout` section of [`@exadev/config`](https://github.com/ExaDev/config), and file-tree and cross-config checks compare two files or read git state. Each check reports violations with a stable code, a message, a file and, where it has one, a position.
3
+ Conformance checks for a pnpm workspace that ESLint cannot express. Type-graph checks run on [ts-morph](https://ts-morph.com), import-graph checks run on [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) with rules generated from the shared `layout` section of [`@exadev/config`](https://github.com/ExaDev/config), file-tree and cross-config checks compare two files or read git state, workflow checks read GitHub Actions files, and settings checks read the repository's settings through the GitHub API. Each check reports violations with a stable code, a message, a file and, where it has one, a position.
4
4
 
5
5
  It complements the workspace architecture rules of [`@exadev/eslint-config`](https://github.com/ExaDev/eslint-config), it does not replace them. Those rules run in the editor and check what `package.json` files declare (`no-uphill-dependency` and `no-dependency-cycle`), incrementally and one file at a time. This tool checks what the code does: the import statements that actually cross packages, whether written as a package name, a relative path or a path alias, and everything that needs two files, the type checker or git. Both read the same `layout` section, so one description of the workspace drives both. Naming (`package-name-mirrors-path`) and per-file lint stay in ESLint.
6
6
 
@@ -161,13 +161,71 @@ False positives and blind spots:
161
161
  - Config variants: only the directory searched is looked at, whereas wrangler also finds a config in a parent directory and follows a redirected config; a repository with both `wrangler.json` and `wrangler.toml` is judged by the first, and a `migrations_pattern` that narrows which files are applied is not read. A generator config that computes `out` from the environment is judged by its value in the process running the check.
162
162
  - Scripts: a script is matched by the text of the command, so `drizzle-kit migrate` run against a local database for tests is reported, and a command assembled in a shell variable or run from a file the script calls is not seen. Narrow `packageJsons` to exempt a package.
163
163
 
164
+ ### Workflows
165
+
166
+ The `workflow-*` checks read GitHub Actions workflow files and run offline. Each file under `.github/workflows` is parsed into a model of its events, jobs, the transitive `needs` graph, `if` conditions, effective permissions (a job's own declaration replaces the workflow's, it is not merged with it) and effective environment (workflow, then job, then step), and the checks read that model. A file that is not YAML, or is not shaped like a workflow, fails the run with a configuration error instead of being skipped. Expressions (`${{ ... }}`) are never evaluated: a value that is an expression is treated as unknown, and a check that needs a literal ignores it.
167
+
168
+ Every workflow check takes `workflows` (globs of the files to read, `.github/workflows/*.yml` and `.github/workflows/*.yaml` by default) and `exclude` (globs to leave out), except `workflow-update-bot-cooldown`, which reads the update bots' own configs. Jobs are named by id in the options. These checks live here and not in `@exadev/eslint-config` because they need the cross-job graph that a single-file lint rule cannot see (see [ExaDev/eslint-config#78](https://github.com/ExaDev/eslint-config/issues/78)). Security audits of workflow files (dangerous triggers, token scopes, expression injection) stay with an external auditor such as zizmor or actionlint and are not reimplemented.
169
+
170
+ | Check | Verifies | Codes |
171
+ |---|---|---|
172
+ | `workflow-job-ordering` | A release job needs every deploy job; a documentation deploy job needs every release job and checks out the default branch afresh; a junction job has `if: always()`, lists every other job in `needs` and fails only on `failure` and `cancelled`. | `release-before-deploy`, `docs-deploy-before-release`, `docs-deploy-stale-checkout`, `junction-not-always`, `junction-missing-need`, `junction-fails-on-skipped`, `junction-no-failure-condition` |
173
+ | `workflow-skippable-jobs` | A path filter does not leave a required check pending or skip a job that no junction job reports for. | `trigger-path-filter`, `job-gated-by-path-filter` |
174
+ | `workflow-runner-resolution` | A custom or self-hosted runner label is not named literally in several jobs; a resolver job has a `timeout-minutes` and its output a literal hosted fallback. | `repeated-label`, `resolver-no-timeout`, `resolver-no-fallback`, `resolver-fallback-not-hosted` |
175
+ | `workflow-version-single-source` | A setup step does not hold a literal runtime version when `.nvmrc` or `.tool-versions` already does. | `literal-version` |
176
+ | `workflow-credentials` | Attestation steps have `id-token: write` and `attestations: write`; a publish that passes no token blanks the token variables, or at least requests provenance so that a fallback publish is traceable. | `attestation-permissions`, `tokenless-publish-unprotected` |
177
+ | `workflow-repository-dispatch` | A `repository_dispatch` handler does not push to the default branch, auto-merge without a required-checks rule, push or open pull requests as `GITHUB_TOKEN`, or dispatch to another repository with it. | `pushes-default-branch`, `auto-merge-without-required-checks`, `default-token-triggers-nothing`, `dispatch-other-repository-with-default-token` |
178
+ | `workflow-update-bot-cooldown` | Dependabot has a `cooldown` in every update entry, and Renovate sets `minimumReleaseAge`. | `dependabot-no-cooldown`, `renovate-no-minimum-release-age` |
179
+ | `workflow-merge-group` | A workflow that runs for `pull_request` also has a `merge_group` trigger. | `missing-trigger` |
180
+ | `workflow-action-pinning` | Every `uses` follows the pinning policy. | `not-pinned`, `docker-not-pinned`, `missing-ref` |
181
+
182
+ Codes are `<check>/<code>`, so `workflow-job-ordering/release-before-deploy`. The options and the limits of each check:
183
+
184
+ **`workflow-job-ordering`** takes `releaseJobs` (`release`), `deployJobs` (`deploy`), `docsDeployJobs` (`docs-deploy`), `junctionJobs` (`required-checks`), `junctionExempt` (jobs the junction job need not wait for) and `defaultBranch` (`main`). A role with no job in a workflow is not judged there. Ordering is judged through the transitive `needs` graph, but the junction job must list each job directly, because `needs.*.result` covers only direct needs; a job that itself needs the junction job is never required of it, nor is a job that never runs for a pull request (an `if` that compares `github.ref`, `github.ref_name` or `github.event_name`, mentions neither `pull_request` nor `merge_group`; in an `&&` one such operand is enough, in an `||` every operand must be one, since the merge queue is where the required check decides the merge and a disjunction can hold for a pull request through another operand), a job that needs such a job, or one named in `junctionExempt`. A documentation job's checkout is correct when its `ref` is `${{ github.event.repository.default_branch }}` or the default branch written literally; no `ref` means the triggering commit, which predates the release commit. The junction job's pass condition is read as text: it must read the results of its needs (`needs.*.result`, `needs.<job>.result` or `toJSON(needs)`, including a `join(needs.*.result, ' ')` looped over in a script) and name both `'failure'` and `'cancelled'`, or use the `re-actors/alls-green` action, and `'skipped'` or `!= 'success'` is reported, because a skipped job is how a path filter or an `if` keeps a job out. A decision the text does not show is reported as incomplete.
185
+
186
+ **`workflow-skippable-jobs`** takes `junctionJobs` and `pathFilterActions` (`dorny/paths-filter`, `tj-actions/changed-files`, `step-security/changed-files`). A `paths` or `paths-ignore` filter on `pull_request` or `pull_request_target` skips the whole workflow, so a required check from it stays pending; it is reported when the workflow has a junction job, the sign that it produces a required check. A job whose own `if` reads the outputs of a job that runs a path filter action is skipped; it is reported unless a junction job lists it in `needs`, and the fix is to put the condition on its steps, so the job still runs and reports. Limits: a workflow without a junction job is not judged for its trigger filter; a script that computes changed paths itself is not recognised as a path filter; only the filters of the `pull_request` and `pull_request_target` triggers are read, so a `paths` filter on `push` is not reported; only `needs.<job>.outputs` in a job's own `if` is read.
187
+
188
+ **`workflow-runner-resolution`** takes `hostedLabels`, regular expressions for the labels GitHub hosts itself (the standard `ubuntu`, `windows` and `macos` images and their `-latest`, versioned, `-arm`, `-intel`, `-large`, `-xlarge` and `-slim` forms by default; setting it replaces the default). A label that matches none, or a runner group, named literally in more than one job of a workflow is reported at each such job: resolve it once in a resolver job and read it with `runs-on: ${{ fromJson(needs.<resolver>.outputs.<name>) }}`. Each resolver (the job whose output a `fromJson` in a `runs-on` reads) must have `timeout-minutes`, so a runner that never starts does not hold the jobs waiting for it for the default six hours, and its output must carry a literal fallback (`|| '["<label>"]'`) whose labels all match `hostedLabels`, since a fallback to another self-hosted label does not help when the fleet is down. A resolver that is a call of a local reusable workflow is followed through `on.workflow_call.outputs` to the job that sets the output, and the findings are reported there; one in another repository cannot be read and is not judged. Limits: labels are compared within one file, not across files; a larger runner is named by a custom label, so it counts as one; an output is taken to have a fallback when its expression has `||` followed by a string literal, and the last such literal is read as JSON; one that is not valid JSON counts as not hosted.
189
+
190
+ **`workflow-version-single-source`** recognises the setup actions for Node, Python, Go, Java, .NET, Ruby, Bun, Deno, pnpm and Terraform. A step is reported when it gives the version input a literal while `.nvmrc` (for Node) or `.tool-versions` (for any tool it lists, by its asdf name) exists in the working directory; the message names the input that reads the file. A version containing an expression, such as a matrix value, is not a literal. An alias such as `lts/*` is. Limits: only the actions listed are recognised, only the root files are looked for, and composite actions are not read.
191
+
192
+ **`workflow-credentials`**: an attestation step (`actions/attest`, `actions/attest-build-provenance`, `actions/attest-sbom`) needs `id-token: write` and `attestations: write` in the job's effective permissions. Permissions declared nowhere are the repository's default, which the file cannot show and which does not grant `id-token`, so they are reported. A job with `id-token: write` that runs a publish command (`npm`, `pnpm` or `yarn npm publish`, with options such as `-r` or `--filter <package>` before `publish`, `semantic-release`, `changeset publish`, `lerna publish`, bare or behind `pnpm`, `pnpm exec`, `yarn` or `npx`) and passes no token must set `NODE_AUTH_TOKEN` and `NPM_TOKEN` to the empty string (at step, job or workflow level) or request provenance (`--provenance`, `NPM_CONFIG_PROVENANCE: true`, or `publishConfig.provenance: true` in the `package.json` of the working directory); provenance does not stop an inherited token being used, it makes that publish traceable. Limits: commands are read as text where a command starts, so a script file or composite action is not seen; provenance set in another `package.json` is not read; a job is tokenless only if no token variable has a non-empty value anywhere in it; a job that calls a reusable workflow is not read. `registry-url` is deliberately not reported, see below.
193
+
194
+ **`workflow-repository-dispatch`** takes `defaultBranches` (`main` and `master`; the `github.event.repository.default_branch` expression always counts) and `assumeRequiredChecks`. It reads workflows with a `repository_dispatch` trigger, which anyone with write access can start through the API. `pushes-default-branch` reports a `git push` whose destination is the default branch (a leading `+` is ignored, a bare `HEAD` is the checked-out branch, quotes around a word are removed, `git -C <directory>` and `git -c <setting>` before `push` are read, and options that take a value such as `-o` are skipped), a push with no refspec while the default branch is checked out (no `git checkout -b` or `git switch -c` earlier in the job, no non-default `ref` on the checkout) and `stefanzweifel/git-auto-commit-action` on the default branch. `auto-merge-without-required-checks` reports `gh pr merge --auto`, which merges at once when the base branch requires no checks; the file cannot show the rule, so `assumeRequiredChecks: true` states it and `settings-required-checks` verifies it. `default-token-triggers-nothing` reports a push whose checkout kept the default token (no `token` input; a push to a URL that carries its own credentials does not use it), `gh pr create` with `GITHUB_TOKEN` and `peter-evans/create-pull-request` with the default token: events created with `GITHUB_TOKEN` start no workflows, so the result is never checked. `dispatch-other-repository-with-default-token` reports a dispatch (`gh api repos/<o>/<r>/dispatches`, `gh workflow run -R`, `peter-evans/repository-dispatch`) to a repository other than `github.repository` with `GITHUB_TOKEN`, whose access is limited to its own repository. Dispatching in the same repository with `GITHUB_TOKEN` is not reported, because GitHub documents `workflow_dispatch` and `repository_dispatch` as the events that do create runs when made with it ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), [archived](https://web.archive.org/web/20261001051219/https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)). Limits: commands are read as text, so a script file is not seen; a dispatch target written literally as this repository's own name is reported, since the file does not know the name.
195
+
196
+ **`workflow-update-bot-cooldown`** takes `dependabot` (`.github/dependabot.yml`, else `.yaml`) and `renovate` (`renovate.json`, `.renovaterc.json`, `.renovaterc` and `.github/renovate.json`, the first that exists). Every Dependabot `updates` entry needs a `cooldown` with `default-days` or one of the `semver-*-days` above zero. Renovate needs `minimumReleaseAge` at the top level or in a `packageRules` entry, as a duration above zero (`0 days` and `null` wait for nothing). Limits: Dependabot's cooldown does not apply to security updates; a Renovate preset is not resolved (an `extends` entry counts only when its name contains `minimumReleaseAge`); a Renovate config is read as JSON with comments and trailing commas, so other JSON5 syntax and a `renovate` key in `package.json` are not read. A repository with neither bot has nothing to report; a file named in the options that does not exist fails the run.
197
+
198
+ **`workflow-merge-group`** reports a workflow with a `pull_request` trigger and no `merge_group` trigger: GitHub starts no run of it for a queued pull request, so its required checks never report. The file cannot show which checks are required, so a `pull_request` workflow whose checks are not required is reported too; narrow it with `workflows` and `exclude`. `pull_request_target` is left out, since it is not a place for required checks, and a workflow with no `pull_request` trigger is not judged. Limits: a `merge_group` trigger takes only `branches` filters (`paths` is a filter of `push`, `pull_request` and `pull_request_target`), so a workflow path-filtered for pull requests still runs in the queue once it has the trigger, and the `branches` filter of a `merge_group` trigger is not read ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)).
199
+
200
+ **`workflow-action-pinning`** encodes a pinning policy, and does not choose a side silently: pinning to a commit SHA protects against a moved tag or branch, and a ref lets the owner of a shared workflow roll out a fix without every caller changing. The default follows the reasoning that you do not control a third party but do control your own organisation: an action or reusable workflow from outside `organisations` must be pinned by full 40-character commit SHA (`thirdParty: 'sha'`), a Docker image by digest, and one owned by a listed organisation may use any ref (`sameOrganisation: 'ref'`). With no `organisations` configured, nothing is relaxed. `thirdParty` and `sameOrganisation` each take `sha` or `ref`, so either side of the policy can be flipped, `organisations` lists the owners that count as the same organisation, and `allow` exempts `owner/repository` or `owner/*` (for example `actions/*`). Local actions and workflows (`./...`) are not judged. Limits: it checks the form of the ref, not that the SHA belongs to the repository or to a release; composite actions are not read.
201
+
202
+ #### Two conflicts the checks settle
203
+
204
+ **`registry-url` does not disable tokenless publishing, so it is not banned.** `actions/setup-node` writes an `.npmrc` with `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` when `registry-url` is set, and since v7 exports `NODE_AUTH_TOKEN` only if the workflow supplied one ([`src/authutil.ts`](https://github.com/actions/setup-node/blob/main/src/authutil.ts), [archived](https://web.archive.org/web/20260524063017/https://github.com/actions/setup-node/blob/main/src/authutil.ts); the README says trusted publishing is unaffected because it does not use the variable). Its own trusted-publishing example sets `registry-url` ([`docs/advanced-usage.md`](https://github.com/actions/setup-node/blob/main/docs/advanced-usage.md), [archived](https://web.archive.org/web/20260822101733/https://github.com/actions/setup-node/blob/main/docs/advanced-usage.md)), as does npm's ([trusted publishers](https://docs.npmjs.com/trusted-publishers), [archived](https://web.archive.org/web/20260921070919/https://docs.npmjs.com/trusted-publishers)). In the npm CLI the exchange runs in `npm publish` before credentials are read, and on success it overrides the registry's `_authToken` for the request ([`lib/utils/oidc.js`](https://github.com/npm/cli/blob/latest/lib/utils/oidc.js), [archived](https://web.archive.org/web/20260817065212/https://github.com/npm/cli/blob/latest/lib/utils/oidc.js)). The hazard is the failure path: the same code returns without throwing when the exchange fails, and the publish then proceeds with whatever token the `.npmrc` and environment give it (older `setup-node` majors exported the placeholder `XXXXX-XXXXX-XXXXX-XXXXX`, and the runner's own environment, or a `GITHUB_ENV` write by an earlier step, can supply `NODE_AUTH_TOKEN`; a secret reaches a step only when the workflow maps it into `env` or `with`, which `workflow-credentials` already treats as a publish that is not tokenless ([using secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions))). Provenance does not close that path: npm signs it through Sigstore with the CI's own OIDC identity, separately from the registry token (`libnpmpublish`, `lib/publish.js` and `lib/provenance.js`), so a publish that fell back to an inherited token still goes ahead and carries an attestation. So `workflow-credentials` asks for the token variables to be blanked, which is the only protection, or provenance to be requested, which makes a fallback publish traceable, and says nothing about `registry-url`. The semantic-release npm plugin behaves the same way: it tries its own exchange first (`lib/verify-auth.js` and `lib/trusted-publishing/token-exchange.js` of `@semantic-release/npm`) and reads token configuration only when that fails. So a workflow may keep `registry-url`, and one that omits it gains nothing against a successful exchange; the repository's own release job omits it because trusted publishing needs no registry configuration.
205
+
206
+ **Pinning is a policy option.** See `workflow-action-pinning` above.
207
+
208
+ ### Repository settings
209
+
210
+ The `settings-*` checks read repository settings through the GitHub API, so they need a token and are kept apart from the file checks. They run only when the run is given a GitHub client: `runChecks({ github })` in the library, `--settings` on the command line (which reads the token from `GITHUB_TOKEN` or `GH_TOKEN`). Without one they are left out, even when enabled, so the same configuration runs offline; naming one with `--check` or `checks` without a client, or enabling only settings checks and running offline, fails the run instead of reporting a pass. The client is an interface (`GitHubClient`, with `repository` and `branchRules`); `createGitHubClient({ token })` is the implementation over the REST API (GitHub reports the merge-method settings only to a token with write access to the repository, so `repository` fails with a `ConformanceError` for a read-only token), and a test or another host can supply its own. A violation's `file` is the repository, `owner/name`.
211
+
212
+ All three take `repository` (`owner/name`; the `origin` remote of the working directory, as configured before any `insteadOf` rewriting, when omitted) and `branch` (the repository's default branch). Rules are the effective rules of the branch from `GET /repos/{owner}/{repo}/rules/branches/{branch}` ([docs](https://docs.github.com/en/rest/repos/rules), [archived](https://web.archive.org/web/20260924024404/https://docs.github.com/en/rest/repos/rules)), which covers rulesets and not classic branch protection.
213
+
214
+ | Check | Verifies | Codes |
215
+ |---|---|---|
216
+ | `settings-merge-methods` | Only one merge method (`allowed`, `rebase` by default) is available: the repository allows it and no `pull_request` rule of the branch leaves it out. | `method-enabled`, `method-unavailable` |
217
+ | `settings-required-checks` | A `required_status_checks` rule requires the check of each junction job (`junctionJobs`, found in the workflows selected by `workflows` and `exclude`; the check name is the job's `name`, else its id) and some rule sets strict mode. | `no-junction-job`, `no-required-checks`, `required-check-missing`, `not-strict` |
218
+ | `settings-review-thread-resolution` | A `pull_request` rule requires review conversations to be resolved. | `not-required` |
219
+
220
+ Limits: classic branch protection and a merge queue's own merge method are not read, so a repository that uses classic protection is reported as having no required checks; a junction job in a reusable workflow, or a name that is an expression, is not resolved.
221
+
164
222
  ## Command line
165
223
 
166
224
  ```sh
167
- workspace-conformance check [--cwd <directory>] [--check <name>]... [--list]
225
+ workspace-conformance check [--cwd <directory>] [--check <name>]... [--list] [--settings]
168
226
  ```
169
227
 
170
- `--cwd` is the directory that holds the config files (default: the current directory). `--check` runs only that check, which must be enabled, and may be repeated. `--list` prints every check and stops without reading any configuration. Violations are printed to standard error as `file:line:column: code: message`.
228
+ `--cwd` is the directory that holds the config files (default: the current directory). `--check` runs only that check, which must be enabled, and may be repeated. `--list` prints every check and stops without reading any configuration. `--settings` also runs the enabled `settings-*` checks, which read the repository through the GitHub API with the token in `GITHUB_TOKEN` or `GH_TOKEN`; without it they are left out and the run is offline. Violations are printed to standard error as `file:line:column: code: message`.
171
229
 
172
230
  | Exit status | Meaning |
173
231
  |---|---|
@@ -185,7 +243,7 @@ const result = await runChecks({ cwd: process.cwd() });
185
243
  // result.violations: all of them; result.exitCode: 0 or 1
186
244
  ```
187
245
 
188
- `runChecks` loads both sections from `cwd` unless you pass `config` (the `conformance` section) or `layout`, which are validated with the same schemas as the ones it loads, and takes `checks` to run a subset and `configFiles` (`alias`, `fsCache`, `trust`, `merge`) for how config files load. Violations never throw. It throws `ConformanceError` when the checks cannot run and `ConfigValidationError` (from `@exadev/config`) when a section is invalid. Use it in a vitest test so the conformance suite runs with the rest:
246
+ `runChecks` loads both sections from `cwd` unless you pass `config` (the `conformance` section) or `layout`, which are validated with the same schemas as the ones it loads, and takes `checks` to run a subset, `github` (a `GitHubClient`, which switches the settings checks on) and `configFiles` (`alias`, `fsCache`, `trust`, `merge`) for how config files load. Violations never throw. It throws `ConformanceError` when the checks cannot run and `ConfigValidationError` (from `@exadev/config`) when a section is invalid. Use it in a vitest test so the conformance suite runs with the rest:
189
247
 
190
248
  ```ts
191
249
  import { runChecks } from 'workspace-conformance';
@@ -198,7 +256,7 @@ it('conforms to the workspace layout', async () => {
198
256
  });
199
257
  ```
200
258
 
201
- Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `commandTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager` and `migrationsDirectory`. These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
259
+ Every check is also exported as a function taking `{ cwd, options }` (and `layout` for the import checks): `aggregateMappers`, `commandTypes`, `importUphill`, `importRankSkip`, `importCrossSlice`, `importIsolatedGroups`, `importCycles`, `instructionSymlinks`, `singleStorybook`, `commitTypes`, `dockerfilePackageManager`, `migrationsDirectory`, the workflow checks (`workflowJobOrdering`, `workflowSkippableJobs`, `workflowRunnerResolution`, `workflowVersionSingleSource`, `workflowCredentials`, `workflowRepositoryDispatch`, `workflowUpdateBotCooldown`, `workflowMergeGroup` and `workflowActionPinning`) and, taking `{ cwd, options, github }`, the settings checks (`settingsMergeMethods`, `settingsRequiredChecks` and `settingsReviewThreadResolution`). These take options and a layout as already validated and do not check them again; go through `runChecks` for validation. `parseWorkflow` and `loadWorkflows` expose the workflow model the workflow checks read. A check is a function that returns `Promise<readonly Violation[]>`, where a `Violation` is `{ code, message, file, location? }`.
202
260
 
203
261
  ### Proving a handler map is exhaustive
204
262
 
@@ -243,8 +301,8 @@ pnpm test:mutation # Stryker; CI runs it on manual dispatch only
243
301
 
244
302
  CI also runs `pnpm exec publint` and `pnpm exec attw --pack` after the build, and installs the packed tarball into a scratch project on each supported Node line and both cosmiconfig majors (`test/package/install-check.sh`), where it runs the checks as ESM, as CommonJS and through the bin. dependency-cruiser is an ES module only, so the CommonJS build loads it with a dynamic `import()`.
245
303
 
246
- Each check has a fixture that violates it and one that does not, under `test/fixtures`; the file-tree checks that need git build a repository in a temporary directory. The fixtures are excluded from the project's own typecheck and lint.
304
+ Each check has a fixture that violates it and one that does not, under `test/fixtures`; the file-tree checks that need git build a repository in a temporary directory, the workflow checks have their fixtures under `test/fixtures/workflows`, and the settings checks run against an in-memory `GitHubClient`. The fixtures are excluded from the project's own typecheck and lint.
247
305
 
248
- CI selects its runner with `ExaDev/runner-fallback-action` (self-hosted fleet first, Blacksmith as fallback). The release job stays on a GitHub-hosted runner because npm trusted publishing needs one, and publishes with provenance through OIDC, with no stored token. Commits follow [Conventional Commits](https://www.conventionalcommits.org); semantic-release derives the version from them.
306
+ CI selects its runner with `ExaDev/runner-fallback-action` (self-hosted fleet first, Blacksmith as fallback). The release job stays on a GitHub-hosted runner because npm trusted publishing needs one, and publishes with provenance through OIDC, with no stored token. The repository deploy key (the `RELEASE_DEPLOY_KEY` secret) is a ruleset bypass actor, so it is written to disk by the step immediately before the release step, and only the release step's own environment carries `GIT_SSH_COMMAND` (absolute `/usr/bin/ssh`, no ssh config, a pinned GitHub host key, only the ssh protocol allowed); the key is deleted afterwards. The build and package checks run before the key exists and the release step sets `NPM_CONFIG_IGNORE_SCRIPTS`, so no dev dependency runs beside the key, the job's actions are pinned by commit SHA, and the checkout, the action setup and the dependency install run with no write-capable credential in git config. That limits what code from those steps can read; it is not a sandbox, since a process started earlier in the job still runs as the same user. `release.config.ts` sets an SSH `repositoryUrl`, so semantic-release pushes the release commit and tag over SSH. The default `GITHUB_TOKEN` cannot bypass a branch ruleset on `main`, and semantic-release falls back to pushing as it only when the SSH push fails, so the job dry-run pushes first and fails before publishing if the key does not authenticate, and refuses the https URL outright. Commits follow [Conventional Commits](https://www.conventionalcommits.org); semantic-release derives the version from them.
249
307
 
250
308
  `README.md`, `AGENTS.md` and `CLAUDE.md` are one file: the two instruction files are tracked symlinks to this one.