@avi2dg/checks 0.38.1 → 0.40.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.40.0
6
+
7
+ Released 2026-10-11.
8
+
9
+ ### Features
10
+
11
+ - **delivery:** fold title lint into the ci job and trim docs-only runs [#146](https://github.com/avi2d/checks/pull/146)
12
+
13
+ ## 0.39.0
14
+
15
+ Released 2026-10-11.
16
+
17
+ ### Features
18
+
19
+ - **testing:** prune superseded mutation baselines after main uploads [#144](https://github.com/avi2d/checks/pull/144)
20
+
5
21
  ## 0.38.1
6
22
 
7
23
  Released 2026-10-11.
package/README.md CHANGED
@@ -117,7 +117,33 @@ To consume the kit from a repository:
117
117
  - run: bun run test
118
118
  ```
119
119
 
120
- Add a pull request title lint step in another workflow using `./node_modules/.bin/commitlint`.
120
+ Lint the pull request title in the same job with one step:
121
+
122
+ ```yaml
123
+ - id: title
124
+ name: lint the title
125
+ env:
126
+ PR_TITLE: ${{ github.event.pull_request.title }}
127
+ GH_TOKEN: ${{ github.token }}
128
+ run: bunx checks-commit-title
129
+ ```
130
+
131
+ A repository whose branch protection requires `commitlint` keeps that required status name while `commitlint.yml` goes away.
132
+ The same job reports the title step's outcome under that name on every run, which needs `statuses: write`:
133
+
134
+ ```yaml
135
+ - name: report the title as commitlint
136
+ if: always()
137
+ env:
138
+ GH_TOKEN: ${{ github.token }}
139
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
140
+ OUTCOME: ${{ steps.title.outcome }}
141
+ run: |
142
+ state=failure
143
+ if [ "$OUTCOME" = success ]; then state=success; fi
144
+ gh api "repos/$GITHUB_REPOSITORY/statuses/${HEAD_SHA:-$GITHUB_SHA}" -f state="$state" -f context=commitlint -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" --silent
145
+ ```
146
+
121
147
  A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job, as [checks-ci-wiring](docs/gates/checks-ci-wiring.md#runners) requires.
122
148
  `checks-effect-scope` writes the Effect paths of `oxlint.config.ts` and the kit's severities into `tsconfig.json`, as [The Effect rules](docs/configs/effect-rules.md#language-service) says.
123
149
  `bun run lint` then ends with `checks-lint: <count> gate(s) pass`.
@@ -157,7 +183,7 @@ These bins run on their own:
157
183
  - [`checks-flake`](docs/gates/checks-flake.md) runs the suite on a schedule and records the seeds a flaky test fails with.
158
184
  - [`checks-mutation`](docs/gates/checks-mutation.md) runs Stryker for scoped checks and refuses a full run outside CI.
159
185
  - [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) compares the mutants of two reports and lists each one that regresses.
160
- - [`checks-mutation-baseline`](docs/gates/checks-mutation-baseline.md) restores the newest mutation baseline from `main`, through a cache on a self-hosted runner.
186
+ - [`checks-mutation-baseline`](docs/gates/checks-mutation-baseline.md) restores the newest mutation baseline from `main`, through a cache on a self-hosted runner, and deletes superseded baselines after a `main` upload.
161
187
  - [`checks-subsumed-tests`](docs/gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
162
188
  - [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
163
189
  - [`checks-release-notes`](docs/gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
@@ -166,9 +192,11 @@ These bins run on their own:
166
192
  - [`checks-release-tag`](docs/gates/checks-release-tag.md) tags a landed release commit with its version and dispatches the release workflow on the tag.
167
193
  - [`checks-vendor`](docs/gates/checks-vendor.md) pins each library its `prepare` arguments name to a shared read-only clone and links it under `repos/`.
168
194
  - [`checks-browser`](docs/gates/checks-browser.md) opens a product's built pages in Chrome and fails on the layout, keyboard, motion, accessibility, nesting and asset checks its `browser-checks.json` declares.
195
+ - [`checks-commit-title`](docs/gates/checks-commit-title.md) lints the pull request title in one step of the `ci` job.
196
+ - [`checks-docs-only`](docs/gates/checks-docs-only.md) tells whether a change touches only top-level Markdown and Markdown under `docs/`, so a docs-only change skips the full run.
169
197
 
170
198
  `checks-lint` has [its own page](docs/gates/checks-lint.md), which says which range it resolves.
171
- oxlint and commitlint run through their own tools, as the pages under Related topics say.
199
+ oxlint runs through its own tool, as the pages under Related topics say.
172
200
 
173
201
  ## Upgrade
174
202
 
@@ -14,20 +14,26 @@ It arrives with the kit, since `@commitlint/cli` and `@commitlint/config-convent
14
14
  ## Workflow
15
15
 
16
16
  The lint runs in CI on pull requests, because `jj` never fires a git hook.
17
- Each repository owns `.github/workflows/commitlint.yml` and runs the installed `commitlint` binary in a pull request step.
17
+ Each repository lints the title in one step of its `ci` job with `bunx checks-commit-title`.
18
18
  The workflow lints with the installed kit's `commitlint.config.js`, so every repository holds titles to the same rules.
19
+ The `ci` job reports the title step's outcome as a `commitlint` status on every run, so a required `commitlint` status stays while `commitlint.yml` goes away.
20
+ That post needs the workflow's `statuses: write` permission.
21
+ The title lint runs before the docs-only filter, so a change to top-level Markdown and `docs/` Markdown alone still lints its title.
19
22
 
20
23
  ## What it lints
21
24
 
22
25
  It lints the pull request title and nothing else.
23
26
  The title is the enforced subject because a squash merge uses it as the main commit subject, and per-commit messages are not linted.
24
- GitHub appends ` (#N)` to the squashed subject, so the workflow lints the title with that suffix attached, and the header length limit applies to the landed subject, not the bare title.
25
- The workflow moves git's comment character off `#`, so a title starting with `#` is linted like any other.
26
- The workflow also triggers on `workflow_dispatch`, since GitHub holds the `pull_request` runs of a release pull request the workflow token opens until a maintainer approves them, as [checks-release-pr](../gates/checks-release-pr.md) says.
27
- A dispatched run carries no pull request, so the workflow reads the title of the one open pull request its branch heads, and fails when there is none.
27
+ GitHub appends the pull request number to the squashed subject, so the bin lints the title with ` (#0000)` attached.
28
+ The header length limit applies to the landed subject, not the bare title.
29
+ The bin moves the git comment character off `#`, so a title starting with `#` is linted like any other.
30
+ The `ci` job also triggers on `workflow_dispatch`, as [checks-release-pr](../gates/checks-release-pr.md) says.
31
+ GitHub holds the `pull_request` runs of a release pull request the workflow token opens until a maintainer approves them.
32
+ A dispatched run carries no pull request, so the bin reads the title of the one open pull request its branch heads.
33
+ It fails when there is none.
28
34
  That lookup needs the workflow's `pull-requests: read` permission.
29
- It reports a dispatched run's result on the head commit as a `commitlint` status, since GitHub keeps a dispatched run's check off the pull request.
30
- The kit's own `.github/workflows/commitlint.yml` shows the step.
35
+ The `commitlint` status covers a dispatched run too, since GitHub keeps a dispatched run's check off the pull request.
36
+ The kit's own `.github/workflows/ci.yml` shows the step.
31
37
 
32
38
  It never sees a commit's author or committer fields, nor the `Co-authored-by` trailer GitHub writes from a foreign author when it squashes, so it cannot enforce who a commit belongs to.
33
39
  [checks-commit-identity](../gates/checks-commit-identity.md) is that enforcement.
@@ -8,7 +8,8 @@ audience: consumers
8
8
 
9
9
  ## What it checks
10
10
 
11
- The kit requires `./node_modules/.bin/commitlint` on every pull request.
11
+ The kit requires `bunx checks-commit-title` on every pull request.
12
+ A step that calls `bun src/delivery/commit-title.ts` or `./node_modules/.bin/commitlint` directly still satisfies the check.
12
13
  It requires `bun run lint`, `bun run build`, `bun run typecheck` and `bun run test` for each of those scripts that `package.json` defines.
13
14
  A `build` script also requires `git diff --exit-code`.
14
15
  The target branch is the pull request base in CI, else the branch `refs/remotes/origin/HEAD` names, else the default branch of the repository in GitHub's event, else `main`.
@@ -0,0 +1,57 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-commit-title
6
+
7
+ `checks-commit-title` lints the pull request title against the shared commitlint config in one step of the `ci` job.
8
+
9
+ ## What it checks
10
+
11
+ It lints the pull request title and nothing else.
12
+ The title comes from `PR_TITLE` when the event carries one.
13
+ A dispatched run carries no pull request, so the bin reads the title of the one open pull request its branch heads.
14
+ It fails when there is none.
15
+ A run with no title to lint passes and says so.
16
+ The bin lints the title with ` (#0000)` attached, since GitHub appends the pull request number to the squashed subject.
17
+ It moves the git comment character off `#`, so a title starting with `#` is linted like any other.
18
+
19
+ ## What it reads
20
+
21
+ It reads `PR_TITLE` and `GITHUB_EVENT_NAME` from the environment.
22
+ A dispatched run also reads `GITHUB_REPOSITORY`, `GITHUB_REPOSITORY_OWNER` and `GITHUB_REF_NAME`, and calls `gh api` with `GH_TOKEN` for the open pull request.
23
+ It lints with the installed kit's commitlint config through the installed commitlint cli, as [The commit message lint](../configs/commit-messages.md) says.
24
+
25
+ ## Arguments
26
+
27
+ The bin takes no arguments.
28
+
29
+ ## Exit codes
30
+
31
+ | Code | Result |
32
+ | --- | --- |
33
+ | 0 | The title lints clean, or there is no title to lint. |
34
+ | 1 | The title breaks the rules, or no one open pull request heads the branch. |
35
+ | 2 | The kit config or binary cannot be resolved, or a dispatched run misses its environment. |
36
+
37
+ ## Sample output
38
+
39
+ ```
40
+ ⧗ --- input ---
41
+ # feat(lint): add x (#0000)
42
+ ✖ subject may not be empty [subject-empty]
43
+ ✖ type may not be empty [type-empty]
44
+
45
+ ✖ found 2 problems, 0 warnings
46
+ ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
47
+ ```
48
+
49
+ ## When it runs
50
+
51
+ The `ci` job runs it on `pull_request` and `workflow_dispatch`, as [checks-ci-wiring](checks-ci-wiring.md) requires.
52
+ The job then posts its outcome as a `commitlint` status, as [The commit message lint](../configs/commit-messages.md) says.
53
+
54
+ ## Related topics
55
+
56
+ - [The commit message lint](../configs/commit-messages.md)
57
+ - [checks-ci-wiring](checks-ci-wiring.md)
@@ -0,0 +1,50 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-docs-only
6
+
7
+ `checks-docs-only` tells whether a change touches only top-level Markdown and Markdown under `docs/`, so a docs-only change skips the full `ci` run.
8
+
9
+ ## What it checks
10
+
11
+ A change is docs-only when every file it touches is a `.md` file at the top level or under `docs/`, and it touches at least one file.
12
+ Markdown anywhere else, such as a test fixture under `tests/` or a template under `dist/`, keeps the full run.
13
+ A rename counts both sides.
14
+ An empty change and a change with no range keep the full run.
15
+
16
+ ## What it reads
17
+
18
+ It reads the base and head refs from its arguments.
19
+ It compares their merge base with the head through `git merge-base` and `git diff`.
20
+
21
+ ## Arguments
22
+
23
+ ```sh
24
+ checks-docs-only <base-ref> <head-ref>
25
+ ```
26
+
27
+ With no arguments, the bin reports `docs-only=false`.
28
+
29
+ ## Exit codes
30
+
31
+ | Code | Result |
32
+ | --- | --- |
33
+ | 0 | The bin classified the change, and the verdict is on stdout. |
34
+ | 2 | The range cannot be decoded. |
35
+
36
+ ## Sample output
37
+
38
+ ```
39
+ docs-only=true
40
+ ```
41
+
42
+ ## When it runs
43
+
44
+ The `ci` job has a filter step that appends the verdict to `GITHUB_OUTPUT`, and the typecheck and test steps skip when the verdict reads `true`.
45
+ The build, its `git diff --exit-code` and `bun run lint` still run, so `checks-docs` and the generated blocks hold on a docs-only change.
46
+ The job itself always runs and reports, so every required check stays green on a docs-only change.
47
+
48
+ ## Related topics
49
+
50
+ - [checks-ci-wiring](checks-ci-wiring.md)
@@ -4,7 +4,8 @@ audience: consumers
4
4
  ---
5
5
  # checks-mutation-baseline
6
6
 
7
- `checks-mutation-baseline` restores the newest mutation baseline that `main` published, and on a self-hosted runner it keeps each baseline it downloads so the next job copies it instead.
7
+ `checks-mutation-baseline` restores the newest mutation baseline that `main` published, and deletes superseded baselines after a `main` upload.
8
+ On a self-hosted runner it keeps each baseline it downloads, so the next job copies it instead.
8
9
 
9
10
  ## What it checks
10
11
 
@@ -22,12 +23,23 @@ It takes the report from `mutation.json` at the artifact's root, or else from `m
22
23
 
23
24
  It creates the directories each destination needs.
24
25
 
26
+ With `--prune` it deletes the repository's superseded baselines instead of restoring one.
27
+ It keeps the newest unexpired `mutation-baseline` among the runs the restore reads, and the newest unexpired `mutation-baseline-full` the restore takes first.
28
+ It also keeps the unexpired baselines of the run it runs in, which `GITHUB_RUN_ID` names.
29
+ The restore reads only successful runs, so it takes them once that run succeeds.
30
+ A failed artifact lookup while it picks what to keep stops it before it deletes anything.
31
+ Treating that artifact as absent would delete the one the restore takes.
32
+ It deletes only artifacts with exactly those two names in the repository it runs in.
33
+ It deletes only unexpired artifacts, since the restore never reads an expired one.
34
+ With `--dry-run` it prints what it would delete and what it would keep, and deletes nothing.
35
+ It deletes through `gh`, which needs a token that deletes the repository's Actions artifacts, such as `GH_TOKEN: ${{ github.token }}` with `actions: write`.
36
+
25
37
  ## What it reads
26
38
 
27
39
  It reads the runs and their artifacts through `gh`, which needs a token that reads the repository's Actions, such as `GH_TOKEN: ${{ github.token }}` with `actions: read`.
28
40
  The `mutation-baseline` artifact holds `stryker-incremental.json` and `mutation/mutation.json`, which an upload of `reports/stryker-incremental.json` and `reports/mutation/mutation.json` writes.
29
41
  The `mutation-baseline-full` artifact holds `mutation.json` at its root, which an upload of `reports/mutation/mutation.json` alone writes.
30
- It reads `RUNNER_ENVIRONMENT` and `HOME` to place the runner cache.
42
+ It reads `RUNNER_ENVIRONMENT` and `HOME` to place the runner cache, and `--prune` reads `GITHUB_RUN_ID`.
31
43
 
32
44
  ## The runner cache
33
45
 
@@ -51,9 +63,12 @@ When `HOME` is unset it works the same way, and prints to stderr that the runner
51
63
  ```sh
52
64
  checks-mutation-baseline <incremental-dest> [mutation-json-dest]
53
65
  checks-mutation-baseline --full <mutation-json-dest>
66
+ checks-mutation-baseline --prune [--dry-run]
54
67
  ```
55
68
 
56
69
  `--full` restores only the report of a run that started with no state.
70
+ `--prune` deletes superseded baselines after an upload.
71
+ `--dry-run` lists what `--prune` would delete, and deletes nothing.
57
72
 
58
73
  ## Exit codes
59
74
 
@@ -63,6 +78,11 @@ checks-mutation-baseline --full <mutation-json-dest>
63
78
  | 2 | the arguments do not parse, `gh` cannot list the runs, the cache cannot be written, or another job removed the entry before it was copied |
64
79
 
65
80
  A failed artifact lookup or download prints the `gh` error and moves on, as a missing artifact does.
81
+ With `--prune`, `0` means it deleted the superseded artifacts, found none, or could not delete some.
82
+ `2` means the arguments do not parse, or `gh` cannot list the runs, a run's artifacts or the repository's artifacts.
83
+ A failed delete prints a `::warning::` annotation naming the artifact and the `gh` error, moves on, and the summary names the artifacts it could not delete.
84
+ A failed delete still exits `0`, since a failed step fails the `main` run and the restore then skips the baseline that run uploaded.
85
+ The next `main` run's prune deletes what this one missed.
66
86
 
67
87
  ## Sample output
68
88
 
@@ -72,6 +92,12 @@ mutation-baseline: restored it from the runner's cache, mutation-baseline artifa
72
92
 
73
93
  A miss prints `downloaded it into the runner's cache` instead, and a hosted runner prints `downloaded it`.
74
94
 
95
+ ```
96
+ mutation-baseline: deleted artifacts 11542133901, 11553708473, 11632727585; kept mutation-baseline artifact 11670749656 from run 38055231501
97
+ ```
98
+
99
+ With `--dry-run` the summary says what it would delete and what it would keep.
100
+
75
101
  ## When it runs
76
102
 
77
103
  Only a CI step the repository writes runs it.
@@ -84,6 +110,27 @@ A baseline job restores the state the previous `main` run left before an increme
84
110
  run: bunx checks-mutation-baseline reports/stryker-incremental.json
85
111
  ```
86
112
 
113
+ A baseline job uploads the state the incremental run wrote, then on `main` prunes the superseded baselines:
114
+
115
+ ```yaml
116
+ - uses: actions/upload-artifact@v4
117
+ with:
118
+ name: mutation-baseline
119
+ path: |
120
+ reports/mutation/mutation.json
121
+ reports/stryker-incremental.json
122
+ retention-days: 7
123
+ - name: Prune superseded baselines
124
+ if: github.ref == 'refs/heads/main'
125
+ env:
126
+ GH_TOKEN: ${{ github.token }}
127
+ run: bunx checks-mutation-baseline --prune
128
+ ```
129
+
130
+ The incremental upload keeps 7 days of retention, since the restore reads only the newest `main` run.
131
+ The full upload keeps 30 days, since full runs start weekly.
132
+ A pull request's scope step loses its coverage when no full baseline outlives the gap.
133
+
87
134
  A pull request's scope step restores the full report with `--full`:
88
135
 
89
136
  ```yaml
@@ -47,7 +47,7 @@ The repository needs **Allow GitHub Actions to create and approve pull requests*
47
47
  checks-release-pr <workflow>...
48
48
  ```
49
49
 
50
- Each argument names a workflow file under `.github/workflows/` whose jobs report the checks the default branch requires, such as `ci.yml` and `commitlint.yml`.
50
+ Each argument names a workflow file under `.github/workflows/` whose jobs report the checks the default branch requires, such as `ci.yml`.
51
51
  Each named workflow triggers on `workflow_dispatch`.
52
52
  Name no workflow that runs something else on dispatch, such as a mutation baseline.
53
53
 
@@ -63,7 +63,7 @@ Name no workflow that runs something else on dispatch, such as a mutation baseli
63
63
  A run that opens the pull request prints the build's own output, then one line:
64
64
 
65
65
  ```
66
- release-pr: opened https://github.com/acme/widget/pull/12 to release 0.4.0, and dispatched ci.yml, commitlint.yml on release/main
66
+ release-pr: opened https://github.com/acme/widget/pull/12 to release 0.4.0, and dispatched ci.yml on release/main
67
67
  ```
68
68
 
69
69
  A run on the same `HEAD` the next day prints one line:
@@ -115,7 +115,7 @@ jobs:
115
115
  - name: open or refresh the release pull request
116
116
  env:
117
117
  GH_TOKEN: ${{ github.token }}
118
- run: ./node_modules/.bin/checks-release-pr ci.yml commitlint.yml
118
+ run: ./node_modules/.bin/checks-release-pr ci.yml
119
119
  tag:
120
120
  if: "github.event_name == 'push' && startsWith(github.event.head_commit.message, 'chore: release ')"
121
121
  runs-on: ubuntu-latest
@@ -150,8 +150,7 @@ Nothing requires them, and approving them runs the same checks again.
150
150
 
151
151
  The daily release needs the repository's other workflows to accept the dispatch:
152
152
 
153
- - `.github/workflows/ci.yml` and `.github/workflows/commitlint.yml` trigger on `workflow_dispatch`, grant `statuses: write`, and end each required job with the step below.
154
- `commitlint.yml` also grants `pull-requests: read`, which the title lookup needs.
153
+ - `.github/workflows/ci.yml` triggers on `workflow_dispatch`, grants `statuses: write` and `pull-requests: read`, and ends each required job with the step below.
155
154
  - The title lint reads the title of the one open pull request its branch heads when the event carries none, as [Commit messages](../configs/commit-messages.md) says.
156
155
  - `.github/workflows/release.yml` triggers on `workflow_dispatch` and refuses a ref that is not a tag, as [checks-release-notes](checks-release-notes.md) shows.
157
156
  - **Allow GitHub Actions to create and approve pull requests** is on, which the call after the step sets.
@@ -8,7 +8,7 @@ audience: consumers
8
8
 
9
9
  ## What it checks
10
10
 
11
- It reads the subject of `HEAD`, and does nothing unless the subject is `chore: release <version>`, with or without the ` (#N)` a squash merge adds.
11
+ It reads the subject of `HEAD`, and does nothing unless the subject is `chore: release <version>`, with or without the pull request number a squash merge appends.
12
12
  It refuses a release commit whose `package.json` holds another version.
13
13
  It tags `HEAD` as `v<version>` and pushes the tag to `origin`.
14
14
  A tag the workflow token pushes starts no `push` workflow, so it then dispatches the workflow its argument names on the tag.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,8 @@
40
40
  "src/testing/test-report.ts",
41
41
  "src/testing/flake.ts",
42
42
  "src/delivery/commit-identity.ts",
43
+ "src/delivery/commit-title.ts",
44
+ "src/delivery/docs-only.ts",
43
45
  "src/testing/mutation.ts",
44
46
  "src/testing/mutation-scope.js",
45
47
  "src/testing/mutation-guard-plugin.js",
@@ -132,6 +134,8 @@
132
134
  "checks-test": "src/testing/test.ts",
133
135
  "checks-flake": "src/testing/flake.ts",
134
136
  "checks-commit-identity": "src/delivery/commit-identity.ts",
137
+ "checks-commit-title": "src/delivery/commit-title.ts",
138
+ "checks-docs-only": "src/delivery/docs-only.ts",
135
139
  "checks-mutation": "src/testing/mutation.ts",
136
140
  "checks-mutation-compare": "src/testing/mutation-compare.ts",
137
141
  "checks-mutation-baseline": "src/testing/mutation-baseline.ts",
@@ -11,6 +11,7 @@ export type { Command };
11
11
  export type Gate = {
12
12
  readonly command: string;
13
13
  readonly words: Command;
14
+ readonly alternatives: readonly Command[];
14
15
  };
15
16
 
16
17
  export type Declaration = {
@@ -63,7 +64,10 @@ export class WiringError extends Schema.TaggedError<WiringError>()("WiringError"
63
64
  const WORKFLOWS = ".github/workflows";
64
65
  const SCRIPTED = ["lint", "build", "typecheck", "test"];
65
66
  const BUILT_TREE = "git diff --exit-code";
66
- const TITLE_LINT = "./node_modules/.bin/commitlint";
67
+ const TITLE_LINT = "bunx checks-commit-title";
68
+ // The gate holds until every consumer migrates, so the direct commitlint call still counts.
69
+ // The kit calls its own file through bun, since bunx never resolves the working tree.
70
+ const TITLE_CALLS = ["bun src/delivery/commit-title.ts", "./node_modules/.bin/commitlint"];
67
71
  const Manifest = Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) });
68
72
  // Without these a pull_request workflow never sees the commits a pull request pushes.
69
73
  const GATING_TYPES = ["opened", "synchronize"];
@@ -207,6 +211,7 @@ function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates:
207
211
  const entryPoint = entryPointCommand(gate.words, lintGates);
208
212
  const commands = [
209
213
  { command: gate.command, words: gate.words },
214
+ ...gate.alternatives.map((words) => ({ command: gate.command, words })),
210
215
  ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
211
216
  ];
212
217
  const invoking = steps.flatMap(({ location, blocker, script }) => {
@@ -258,7 +263,11 @@ export function requiredCommands(scripts: readonly string[]): readonly string[]
258
263
 
259
264
  export function declarationFor(scripts: readonly string[], branch: string): Declaration {
260
265
  return {
261
- gates: requiredCommands(scripts).map((command) => ({ command, words: command.split(" ") })),
266
+ gates: requiredCommands(scripts).map((command) => ({
267
+ command,
268
+ words: command.split(" "),
269
+ alternatives: command === TITLE_LINT ? TITLE_CALLS.map((call) => call.split(" ")) : [],
270
+ })),
262
271
  defaultBranch: branch,
263
272
  lintGates: KIT_GATES,
264
273
  };
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { collect } from "../core/git.ts";
4
+ import { runMain } from "../core/main.ts";
5
+
6
+ const NAME = "commit-title";
7
+
8
+ class TitleSetup extends Schema.TaggedError<TitleSetup>()("TitleSetup", {
9
+ message: Schema.String,
10
+ }) {}
11
+
12
+ const setup = (message: string): TitleSetup => new TitleSetup({ message });
13
+
14
+ // The cli entry stays inside its own package, so this resolves from the install cache as well as node_modules.
15
+ const cliOf = Effect.fn("cliOf")(function* () {
16
+ const entry = yield* Effect.try({
17
+ try: () => new URL("./cli.js", import.meta.resolve("@commitlint/cli")),
18
+ catch: () => setup("cannot resolve @commitlint/cli from the installed kit"),
19
+ });
20
+ return yield* (yield* Path.Path).fromFileUrl(entry);
21
+ });
22
+
23
+ // The config ships with the kit, so it sits two levels above this file wherever the kit is installed.
24
+ const configOf = Effect.fn("configOf")(function* () {
25
+ const file = yield* Effect.try({
26
+ try: () => new URL("../../commitlint.config.js", import.meta.url),
27
+ catch: () => setup("cannot resolve commitlint.config.js from the installed kit"),
28
+ });
29
+ return yield* (yield* Path.Path).fromFileUrl(file);
30
+ });
31
+
32
+ const dispatchedTitle = Effect.fn("dispatchedTitle")(function* () {
33
+ const repository = yield* Config.String("GITHUB_REPOSITORY").pipe(Config.withDefault(""));
34
+ const owner = yield* Config.String("GITHUB_REPOSITORY_OWNER").pipe(Config.withDefault(""));
35
+ const ref = yield* Config.String("GITHUB_REF_NAME").pipe(Config.withDefault(""));
36
+ if (repository === "" || owner === "" || ref === "") {
37
+ return yield* setup("a dispatched run lints the open pull request its branch heads, so it needs GITHUB_REPOSITORY, GITHUB_REPOSITORY_OWNER and GITHUB_REF_NAME");
38
+ }
39
+ // A dispatched run carries no pull request, so the title comes from the one open pull request the branch heads.
40
+ const found = yield* collect("gh", [
41
+ "api",
42
+ `repos/${repository}/pulls?head=${owner}:${ref}&state=open`,
43
+ "--jq",
44
+ `if length == 1 then .[0].title else error("no one open pull request heads the branch") end`,
45
+ ]);
46
+ const title = found.stdout.trim();
47
+ if (found.exitCode !== 0 || title === "" || title === "null") {
48
+ const report = found.stderr.trim() === "" ? found.stdout.trim() : found.stderr.trim();
49
+ if (report !== "") yield* Console.error(report);
50
+ return undefined;
51
+ }
52
+ return title;
53
+ });
54
+
55
+ const lintTitle = Effect.fn("lintTitle")(function* (title: string) {
56
+ const system = yield* FileSystem.FileSystem;
57
+ const path = yield* Path.Path;
58
+ const file = path.join(yield* system.makeTempDirectoryScoped({ prefix: "checks-commit-title-" }), "pr-title");
59
+ yield* system.writeFileString(file, `${title} (#0000)`);
60
+ // A title opening with the git comment character lints as empty without this: commentChar moves off the hash.
61
+ const linted = yield* collect(process.execPath, [yield* cliOf(), "--config", yield* configOf(), "--edit", file], undefined, {
62
+ env: { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.commentChar", GIT_CONFIG_VALUE_0: "\x01" },
63
+ });
64
+ if (linted.stdout.trim() !== "") yield* Console.log(linted.stdout.trim());
65
+ if (linted.stderr.trim() !== "") yield* Console.error(linted.stderr.trim());
66
+ return linted.exitCode === 0;
67
+ });
68
+
69
+ const check = Effect.scoped(Effect.gen(function* () {
70
+ const direct = yield* Config.String("PR_TITLE").pipe(Config.withDefault(""));
71
+ if (direct !== "") return yield* lintTitle(direct);
72
+ const event = yield* Config.String("GITHUB_EVENT_NAME").pipe(Config.withDefault(""));
73
+ if (event === "workflow_dispatch") {
74
+ const title = yield* dispatchedTitle();
75
+ if (title === undefined) return false;
76
+ return yield* lintTitle(title);
77
+ }
78
+ yield* Console.log(`${NAME}: no pull request title is available, so there is nothing to lint`);
79
+ return true;
80
+ }));
81
+
82
+ if (import.meta.main) runMain(NAME, check);
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect } from "effect";
3
+ import { changedPaths, git } from "../core/git.ts";
4
+ import { runMain } from "../core/main.ts";
5
+
6
+ const NAME = "docs-only";
7
+
8
+ export function isDocsPath(path: string): boolean {
9
+ const lower = path.toLowerCase();
10
+ return lower.endsWith(".md") && (!lower.includes("/") || lower.startsWith("docs/"));
11
+ }
12
+
13
+ // The verdict goes to stdout as one line, so a step appends it to GITHUB_OUTPUT; the detail goes to stderr.
14
+ const check = Effect.gen(function* () {
15
+ const [base, head] = process.argv.slice(2);
16
+ if (base === undefined || head === undefined) {
17
+ yield* Console.error(`${NAME}: no range is given, so the full run stays`);
18
+ yield* Console.log("docs-only=false");
19
+ return true;
20
+ }
21
+ const mergeBase = (yield* git(["merge-base", base, head])).trim();
22
+ const changes = yield* changedPaths(mergeBase, head, []);
23
+ const paths = changes.flatMap((change) => (change.kind === "renamed" ? [change.from, change.path] : [change.path]));
24
+ const full = paths.filter((path) => !isDocsPath(path));
25
+ if (paths.length === 0) {
26
+ yield* Console.error(`${NAME}: the range changes no file, so the full run stays`);
27
+ yield* Console.log("docs-only=false");
28
+ } else if (full.length === 0) {
29
+ yield* Console.error(`${NAME}: ${paths.length} changed file(s) are all top-level or docs/ Markdown, so the full run can skip`);
30
+ yield* Console.log("docs-only=true");
31
+ } else {
32
+ yield* Console.error(`${NAME}: ${full.length} changed file(s) need the full run`);
33
+ yield* Console.log("docs-only=false");
34
+ }
35
+ return true;
36
+ });
37
+
38
+ if (import.meta.main) runMain(NAME, check);
@@ -10,7 +10,7 @@ class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFai
10
10
  }) {}
11
11
 
12
12
  const NAME = "checks-mutation-baseline";
13
- const USAGE = "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest>";
13
+ const USAGE = "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest> | --prune [--dry-run]";
14
14
  const WORKFLOW = "mutation.yml";
15
15
  const BRANCH = "main";
16
16
  const INCREMENTAL = "stryker-incremental.json";
@@ -21,6 +21,7 @@ const LATEST_ARTIFACT = "mutation-baseline";
21
21
  const FULL_ARTIFACT = "mutation-baseline-full";
22
22
  const LATEST_LIMIT = 20;
23
23
  const FULL_LIMIT = 50;
24
+ const PAGE_SIZE = 100;
24
25
  const FULL_EVENTS = ["schedule", "workflow_dispatch"];
25
26
 
26
27
  // The first of `from` the artifact holds is copied to `to`.
@@ -33,7 +34,7 @@ const Artifact = Schema.Struct({
33
34
  id: Schema.Int,
34
35
  name: Schema.String,
35
36
  expired: Schema.Boolean,
36
- workflow_run: Schema.Struct({ repository_id: Schema.Int }),
37
+ workflow_run: Schema.Struct({ id: Schema.Int, repository_id: Schema.Int }),
37
38
  });
38
39
  type Artifact = typeof Artifact.Type;
39
40
 
@@ -67,12 +68,66 @@ const latestCandidates = Effect.gen(function* () {
67
68
  return newest === undefined ? [] : [newest.databaseId];
68
69
  });
69
70
 
71
+ // All non-pull-request runs newest first: a newer full run holds no baseline, and the newest baseline must survive it.
72
+ const latestRuns = listed(undefined, LATEST_LIMIT).pipe(Effect.map((runs) => newestFirst(runs.filter((run) => run.event !== "pull_request"))));
73
+
70
74
  // Each full event is listed on its own, because one shared window lets push runs crowd out every full run.
71
75
  const fullCandidates = Effect.gen(function* () {
72
76
  const perEvent = yield* Effect.forEach(FULL_EVENTS, (event) => listed(event, FULL_LIMIT));
73
77
  return newestFirst(perEvent.flat());
74
78
  });
75
79
 
80
+ // A successful run uploaded the files, so the newest unexpired artifact is the one the restore takes.
81
+ // A failed lookup aborts rather than counting as absent, since that would delete the artifact the restore takes.
82
+ const keptArtifact = Effect.fn("keptArtifact")(function* (runs: readonly number[], name: string) {
83
+ for (const run of runs) {
84
+ const artifact = yield* artifactOf(run, name);
85
+ if (Option.isSome(artifact)) return Option.some<Kept>({ run, id: artifact.value.id, name });
86
+ }
87
+ return Option.none<Kept>();
88
+ });
89
+
90
+ const everyArtifact = Effect.fn("everyArtifact")(function* () {
91
+ const artifacts: Artifact[] = [];
92
+ let page = 1;
93
+ while (true) {
94
+ const batch = yield* gh(["api", `repos/{owner}/{repo}/actions/artifacts?per_page=${PAGE_SIZE}&page=${page}`]).pipe(
95
+ Effect.flatMap(decodeArtifacts),
96
+ Effect.mapError((cause) => new BaselineFailure({ message: cause.message })),
97
+ );
98
+ artifacts.push(...batch.artifacts);
99
+ if (batch.artifacts.length < PAGE_SIZE) return artifacts;
100
+ page += 1;
101
+ }
102
+ });
103
+
104
+ const missedDeletion = Effect.fn("missedDeletion")(function* (artifact: Artifact) {
105
+ return yield* gh(["api", "-X", "DELETE", `repos/{owner}/{repo}/actions/artifacts/${artifact.id}`]).pipe(
106
+ Effect.as(Option.none<Missed>()),
107
+ Effect.catch((failure) => Effect.succeedSome<Missed>({ id: artifact.id, name: artifact.name, error: failure.message })),
108
+ );
109
+ });
110
+
111
+ const pruneBaselines = Effect.fn("pruneBaselines")(function* (dryRun: boolean) {
112
+ const running = yield* Config.Int("GITHUB_RUN_ID").pipe(Config.option);
113
+ const latest = yield* keptArtifact(yield* latestRuns, LATEST_ARTIFACT);
114
+ const full = yield* keptArtifact(yield* fullCandidates, FULL_ARTIFACT);
115
+ const artifacts = yield* everyArtifact();
116
+ const keeping = [...Option.toArray(latest), ...Option.toArray(full), ...uploadedBy(artifacts, running)];
117
+ const victims = superseded(artifacts, new Set(keeping.map((keep) => keep.id)));
118
+ const removed: number[] = [];
119
+ const missed: Missed[] = [];
120
+ if (!dryRun) {
121
+ for (const victim of victims) {
122
+ const failed = yield* missedDeletion(victim);
123
+ if (Option.isSome(failed)) missed.push(failed.value);
124
+ else removed.push(victim.id);
125
+ }
126
+ }
127
+ yield* Console.log(pruneReport(dryRun, dryRun ? victims.map((victim) => victim.id) : removed, missed, keeping));
128
+ return true;
129
+ });
130
+
76
131
  const artifactOf = Effect.fn("artifactOf")(function* (run: number, name: string) {
77
132
  const found = yield* gh(["api", `repos/{owner}/{repo}/actions/runs/${run}/artifacts?name=${name}`]).pipe(
78
133
  Effect.flatMap(decodeArtifacts),
@@ -98,6 +153,37 @@ const downloaded = Effect.fn("downloaded")(function* (run: number, artifact: Art
98
153
  return fetched && (yield* holds(dir, needed));
99
154
  });
100
155
 
156
+ export type Kept = { readonly run: number; readonly id: number; readonly name: string };
157
+
158
+ export type Missed = { readonly id: number; readonly name: string; readonly error: string };
159
+
160
+ const isBaseline = (artifact: Artifact): boolean => (artifact.name === LATEST_ARTIFACT || artifact.name === FULL_ARTIFACT) && !artifact.expired;
161
+
162
+ // The restore reads only successful runs, so the running run's own uploads join the keepers rather than replace them.
163
+ export function uploadedBy(artifacts: readonly Artifact[], running: Option.Option<number>): readonly Kept[] {
164
+ if (Option.isNone(running)) return [];
165
+ return artifacts
166
+ .filter((artifact) => isBaseline(artifact) && artifact.workflow_run.id === running.value)
167
+ .sort((one, other) => one.id - other.id)
168
+ .map((artifact) => ({ run: running.value, id: artifact.id, name: artifact.name }));
169
+ }
170
+
171
+ export function superseded(artifacts: readonly Artifact[], kept: ReadonlySet<number>): readonly Artifact[] {
172
+ return artifacts.filter((artifact) => isBaseline(artifact) && !kept.has(artifact.id)).sort((one, other) => one.id - other.id);
173
+ }
174
+
175
+ // A workflow command ends at a newline, so the error's own newlines and percent signs are escaped.
176
+ const annotated = (text: string): string => text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
177
+
178
+ export function pruneReport(dryRun: boolean, removed: readonly number[], missed: readonly Missed[], keeping: readonly Kept[]): string {
179
+ const kept = keeping.length === 0 ? "no baseline artifacts" : keeping.map((keep) => `${keep.name} artifact ${keep.id} from run ${keep.run}`).join(", ");
180
+ const deleted = removed.length === 0 ? "nothing to delete" : `deleted artifacts ${removed.join(", ")}`;
181
+ if (dryRun) return `mutation-baseline: dry run, ${removed.length === 0 ? "nothing to delete" : `would delete artifacts ${removed.join(", ")}`}; would keep ${kept}`;
182
+ if (missed.length === 0) return `mutation-baseline: ${deleted}; kept ${kept}`;
183
+ const warnings = missed.map((miss) => `::warning::mutation-baseline: could not delete ${miss.name} artifact ${miss.id}: ${annotated(miss.error)}`);
184
+ return [...warnings, `mutation-baseline: ${deleted}; kept ${kept}; could not delete artifacts ${missed.map((miss) => miss.id).join(", ")}`].join("\n");
185
+ }
186
+
101
187
  export function pruned(entries: readonly string[], kept: number, restoring: string): readonly string[] {
102
188
  return entries
103
189
  .filter((entry) => /^\d+$/.test(entry))
@@ -182,9 +268,16 @@ const runnerCache = Effect.gen(function* () {
182
268
  // An artifact that lacks `needed` counts as no baseline, and `optional` copies only what the artifact holds.
183
269
  type Request = { readonly full: boolean; readonly needed: Copy; readonly optional: readonly Copy[] };
184
270
 
271
+ type PruneRequest = { readonly prune: true; readonly dryRun: boolean };
272
+
185
273
  const isDestination = (arg: string | undefined): arg is string => arg !== undefined && !arg.startsWith("-");
186
274
 
187
- function parsed(args: readonly string[]): Request | undefined {
275
+ function parsed(args: readonly string[]): Request | PruneRequest | undefined {
276
+ if (args[0] === "--prune") {
277
+ const [flag, ...extra] = args.slice(1);
278
+ if ((flag === undefined || flag === "--dry-run") && extra.length === 0) return { prune: true, dryRun: flag !== undefined };
279
+ return undefined;
280
+ }
188
281
  if (args[0] === "--full") {
189
282
  const [report, ...extra] = args.slice(1);
190
283
  if (!isDestination(report) || extra.length > 0) return undefined;
@@ -198,6 +291,7 @@ function parsed(args: readonly string[]): Request | undefined {
198
291
  const mutationBaseline = Effect.gen(function* () {
199
292
  const request = parsed(process.argv.slice(2));
200
293
  if (request === undefined) return yield* new Usage({ message: USAGE });
294
+ if ("prune" in request) return yield* pruneBaselines(request.dryRun);
201
295
  const name = request.full ? FULL_ARTIFACT : LATEST_ARTIFACT;
202
296
  const root = yield* runnerCache;
203
297
  for (const run of yield* request.full ? fullCandidates : latestCandidates) {