@avi2dg/checks 0.59.0 → 0.61.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 +16 -0
- package/docs/design.md +5 -3
- package/docs/gates/checks-browser.md +1 -0
- package/docs/gates/checks-ci-wiring.md +21 -1
- package/docs/gates/checks-release-notes.md +1 -1
- package/docs/gates/checks-release-pr.md +10 -10
- package/docs/gates/checks-release-preflight.md +1 -1
- package/docs/gates/checks-release-tag.md +4 -2
- package/package.json +3 -1
- package/src/delivery/ci-wiring.ts +6 -2
- package/src/delivery/mutation-job.ts +73 -15
- package/src/delivery/release-pr.ts +3 -3
- package/src/delivery/release-schedule.ts +72 -0
- package/src/delivery/release-tag.ts +2 -1
- package/src/delivery/release.ts +3 -1
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.61.0
|
|
6
|
+
|
|
7
|
+
Released 2026-11-01.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **ci-wiring:** refuse a scheduled release in every repository [#22](https://github.com/avi2d/checks/pull/22)
|
|
12
|
+
|
|
13
|
+
## 0.60.0
|
|
14
|
+
|
|
15
|
+
Released 2026-10-31.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **browser:** export the screen sizes for a site to import [#21](https://github.com/avi2d/checks/pull/21)
|
|
20
|
+
|
|
5
21
|
## 0.59.0
|
|
6
22
|
|
|
7
23
|
Released 2026-10-31.
|
package/docs/design.md
CHANGED
|
@@ -84,10 +84,11 @@ Nothing would then say which gate a helper serves.
|
|
|
84
84
|
A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
|
|
85
85
|
The testing directory is named `testing` rather than `tests`, because a `src/tests/` beside the root `tests/` would read as a second suite.
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Seven `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
|
|
88
88
|
`@avi2dg/checks/scripts/test-skips.ts`, `@avi2dg/checks/scripts/comment-matchers.ts` and `@avi2dg/checks/scripts/prose-matchers.ts` point at their files under `src/`.
|
|
89
89
|
`@avi2dg/checks/browser-hooks.ts` points at `src/quality/browser/hooks.ts`, whose types a product's browser hooks import.
|
|
90
90
|
`@avi2dg/checks/browser-checks.json` points at `src/quality/browser/browser-checks.json`, the starter a product copies.
|
|
91
|
+
`@avi2dg/checks/screen-sizes.ts` points at `src/quality/browser/screen-sizes.ts`, whose sizes a product imports.
|
|
91
92
|
`@avi2dg/checks/templates/*` points at `dist/templates/`.
|
|
92
93
|
A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/test-skips.ts`, does not exist.
|
|
93
94
|
|
|
@@ -201,7 +202,8 @@ A daily release pull request would leave each merged change unpublished until a
|
|
|
201
202
|
`checks-release-push` builds the release commit on top of `main` and runs the required checks on it from the `release/<branch>` branch it owns.
|
|
202
203
|
Only then does it move `main` to it without a force, so `main` only ever gains a release commit whose checks passed.
|
|
203
204
|
It tags the commit it landed and nothing else.
|
|
204
|
-
A repository that is no package keeps the
|
|
205
|
+
A repository that is no package keeps the release pull request, and it opens only when someone runs the workflow.
|
|
206
|
+
No release runs on a schedule, in a package or not, since a schedule cuts releases no one asked for.
|
|
205
207
|
`checks-release-pr` writes only to the `release/<branch>` branch it owns, and rebuilds that branch on `main` rather than merging `main` into it, so the changelog it carries is the one the build writes.
|
|
206
208
|
`checks-release-tag` writes only the `v*` tag of a release commit that already landed.
|
|
207
209
|
The `github-release` job's `contents: write` creates or updates the GitHub release from the tag's `CHANGELOG.md` section.
|
|
@@ -217,7 +219,7 @@ So each dispatched job reports its result as a commit status named for the job,
|
|
|
217
219
|
Where a status and a check share a name, branch protection requires both, so the status never passes a pull request whose own check failed.
|
|
218
220
|
The cost is a `workflow_dispatch` trigger and a status step on each workflow a release needs.
|
|
219
221
|
The title lint reads its title from the open pull request, or lints the head subject when none is open.
|
|
220
|
-
The
|
|
222
|
+
The release pull request also needs the repository setting that lets the token open a pull request.
|
|
221
223
|
|
|
222
224
|
## The docs gate judges what a change touches
|
|
223
225
|
|
|
@@ -161,6 +161,7 @@ A product runs the anchor set on every pull request and the full set at night.
|
|
|
161
161
|
Each size in a set is a viewport named for its device and its size, with `device` set, and each phone also sets `touch`.
|
|
162
162
|
A preset sits beside a product's own viewports.
|
|
163
163
|
A name both declare is refused as declared twice, and so is a size that two listed presets both hold.
|
|
164
|
+
A product's own code takes the sizes by importing `SCREEN_SIZES`, `screenSizeName` and `ENLARGED_TEXT_PX` from `@avi2dg/checks/screen-sizes.ts`.
|
|
164
165
|
|
|
165
166
|
<!-- generated screen-sizes: bun run build writes it from SCREEN_SIZES in src/quality/browser/screen-sizes.ts and scripts/doc-blocks.ts -->
|
|
166
167
|
|
|
@@ -84,6 +84,19 @@ This rule skips a repository with no setup-bun step.
|
|
|
84
84
|
A kit release that moves the fleet version turns each consumer red until its pin follows.
|
|
85
85
|
Each refusal names the workflow file and line, the version found and where it was read, and the fleet version.
|
|
86
86
|
|
|
87
|
+
### Release schedule
|
|
88
|
+
|
|
89
|
+
It refuses a workflow that triggers on `schedule` and releases, in every repository, whether or not it publishes to npm.
|
|
90
|
+
A workflow releases when a run step calls `checks-release-pr`, `checks-release-push`, `checks-release-tag` or `checks-release-publish`.
|
|
91
|
+
A call counts directly, through `bunx`, or through `bun` or `bun run` with the bin's path or its source file.
|
|
92
|
+
A call through a `package.json` script counts, as `bun run <script>` where the named script releases.
|
|
93
|
+
A call through a local reusable workflow counts, as a job that uses a file under `.github/workflows/` that releases.
|
|
94
|
+
A call through a repository shell script counts, as a path inside the repository whose file releases.
|
|
95
|
+
A workflow also releases when a run step publishes to npm, as [checks-release-preflight](checks-release-preflight.md) reads it, directly or through one of those paths.
|
|
96
|
+
Text that only names a release bin, such as an `echo`, never counts.
|
|
97
|
+
A release runs when someone starts it, as [checks-release-pr](checks-release-pr.md#when-it-runs) says.
|
|
98
|
+
Each refusal names the workflow file and the line of its `schedule` trigger.
|
|
99
|
+
|
|
87
100
|
## What it reads
|
|
88
101
|
|
|
89
102
|
The bin reads `.github/workflows/*.yml`, `*.yaml`, the `scripts` and `checks` runners in `package.json` and the shell scripts those steps and scripts run from the working tree.
|
|
@@ -102,7 +115,7 @@ It takes no arguments.
|
|
|
102
115
|
| Code | Result |
|
|
103
116
|
| --- | --- |
|
|
104
117
|
| 0 | Every required command has a reachable step. |
|
|
105
|
-
| 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job,
|
|
118
|
+
| 1 | A required command is missing or blocked, a job runs on the wrong runner, a workflow names a missing workflow or job, a setup-bun step drifts from the fleet bun version, or a workflow that releases runs on a schedule. |
|
|
106
119
|
| 2 | A workflow or `package.json` cannot be decoded, or the kit's own `.bun-version` is missing, unreadable or blank. |
|
|
107
120
|
|
|
108
121
|
## Sample output
|
|
@@ -129,6 +142,13 @@ ci-wiring: 1 reference(s) name a missing workflow or job:
|
|
|
129
142
|
.github/workflows/daily-release.yml line 31: names missing workflow commitlint.yml
|
|
130
143
|
```
|
|
131
144
|
|
|
145
|
+
A workflow that releases on a schedule produces a report like this:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
ci-wiring: 1 workflow(s) release on a schedule:
|
|
149
|
+
.github/workflows/daily-release.yml line 3: drop the schedule, so a release runs only when someone starts it
|
|
150
|
+
```
|
|
151
|
+
|
|
132
152
|
## When it runs
|
|
133
153
|
|
|
134
154
|
`checks-lint` runs it in the full run in every repository.
|
|
@@ -175,7 +175,7 @@ The approval setting has no read only check under the release token, so `checks-
|
|
|
175
175
|
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job in either workflow, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
|
|
176
176
|
A release is one commit that holds only the version bump and the built changelog.
|
|
177
177
|
A package repository lands it on every push to `main` through [checks-release-push](checks-release-push.md), which tags it and dispatches this workflow on the tag.
|
|
178
|
-
Another repository takes it as the pull request [checks-release-pr](checks-release-pr.md) opens
|
|
178
|
+
Another repository takes it as the pull request [checks-release-pr](checks-release-pr.md) opens when someone runs the `release-pr` workflow.
|
|
179
179
|
When it lands, [checks-release-tag](checks-release-tag.md) tags the merge commit and dispatches this workflow on the tag.
|
|
180
180
|
A tag the workflow token pushes starts no `push` run, so the workflow triggers on `workflow_dispatch` as well, and refuses a dispatch on a ref that is not a tag.
|
|
181
181
|
A tag a person pushes starts the same workflow through its `push` trigger.
|
|
@@ -18,7 +18,8 @@ It bumps the version `package.json` holds by the kit's rule:
|
|
|
18
18
|
| only fixes, performance changes or reverts | patch | patch |
|
|
19
19
|
|
|
20
20
|
It refuses when the last release tag names a version other than the one `package.json` holds, since an untagged bump means a release landed that nothing published.
|
|
21
|
-
The refusal points at the `tag` job of
|
|
21
|
+
The refusal points at the `tag` job of the workflow it runs in, rerun on the release commit, rather than at a tag pushed by hand.
|
|
22
|
+
That job's own refusal says what to fix.
|
|
22
23
|
It refuses a version that is not a plain `major.minor.patch`.
|
|
23
24
|
It writes the next version into `package.json`, runs `bun run build` so the build writes `CHANGELOG.md`, and commits every tracked file the build changed as `chore: release <version>`.
|
|
24
25
|
The commit's one parent is `HEAD`.
|
|
@@ -35,6 +36,7 @@ It leaves the working tree as it found it.
|
|
|
35
36
|
## What it reads
|
|
36
37
|
|
|
37
38
|
It reads the `v*` tags, the commit subjects since the last one and `package.json` from the checkout.
|
|
39
|
+
It names the workflow it runs in by `GITHUB_WORKFLOW`, or as `release-pr` when that is unset.
|
|
38
40
|
It refuses a shallow checkout, a detached `HEAD` and a working tree with changes to tracked files.
|
|
39
41
|
It reads the release branch from `origin` with `git ls-remote` and `git fetch`.
|
|
40
42
|
It calls the GitHub API through `gh api`, which takes the repository from the checkout's remote and the token from `GH_TOKEN`.
|
|
@@ -74,22 +76,20 @@ release-pr: https://github.com/acme/widget/pull/12 releases 0.4.0 from 3f2a9c81d
|
|
|
74
76
|
|
|
75
77
|
## When it runs
|
|
76
78
|
|
|
77
|
-
The
|
|
79
|
+
The `release-pr` workflow below runs it on the default branch when someone starts it with `gh workflow run release-pr`.
|
|
80
|
+
The workflow has no schedule, so a release is cut only when someone asks for one.
|
|
78
81
|
A package published to npm releases each push to `main` through [checks-release-push](checks-release-push.md) instead, with no pull request.
|
|
79
|
-
|
|
82
|
+
[checks-ci-wiring](checks-ci-wiring.md#release-schedule) refuses a schedule on any workflow that releases.
|
|
80
83
|
|
|
81
84
|
## Running it in CI
|
|
82
85
|
|
|
83
|
-
A repository takes the
|
|
84
|
-
A consuming repository pins daily-release between 03:00 and 04:00 UTC at a minute chosen so repositories do not start together.
|
|
86
|
+
A repository takes the release pull request as the `release-pr` workflow below.
|
|
85
87
|
The `pull-request` job checks out its branch's tip when it starts, so a run that starts late never rebuilds the release on an older commit.
|
|
86
|
-
The `pull-request` job runs
|
|
88
|
+
The `pull-request` job runs when someone starts the workflow, and the `tag` job runs when a release commit lands on `main`, as [checks-release-tag](checks-release-tag.md) says:
|
|
87
89
|
|
|
88
90
|
```yaml
|
|
89
|
-
name:
|
|
91
|
+
name: release-pr
|
|
90
92
|
on:
|
|
91
|
-
schedule:
|
|
92
|
-
- cron: "7 3 * * *"
|
|
93
93
|
workflow_dispatch:
|
|
94
94
|
push:
|
|
95
95
|
branches: [main]
|
|
@@ -155,7 +155,7 @@ The checks it dispatches are the release pull request's own required checks, and
|
|
|
155
155
|
The pull request also lists its own `pull_request` runs as waiting for approval.
|
|
156
156
|
Nothing requires them, and approving them runs the same checks again.
|
|
157
157
|
|
|
158
|
-
The
|
|
158
|
+
The release pull request needs the repository's other workflows to accept the dispatch:
|
|
159
159
|
|
|
160
160
|
- `.github/workflows/ci.yml` triggers on `workflow_dispatch`, grants `statuses: write` and `pull-requests: read`, and ends each required job with the step below.
|
|
161
161
|
- 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.
|
|
@@ -82,7 +82,7 @@ A wrong trusted publisher setting on npm itself surfaces only at the publish ste
|
|
|
82
82
|
## Operator check
|
|
83
83
|
|
|
84
84
|
No endpoint the release token may read exposes the approval setting, so the bin never checks it.
|
|
85
|
-
Turn on **Allow GitHub Actions to create and approve pull requests** under the repository Actions settings before the
|
|
85
|
+
Turn on **Allow GitHub Actions to create and approve pull requests** under the repository Actions settings before the release pull request plans.
|
|
86
86
|
`checks-release-pr` fails while opening the pull request when the setting is off.
|
|
87
87
|
|
|
88
88
|
## Related topics
|
|
@@ -19,11 +19,13 @@ It refuses when `v<version>` already tags another commit.
|
|
|
19
19
|
Before it pushes the tag, it runs `bun run build` and refuses when the build rewrites a committed file.
|
|
20
20
|
That happens when a release pull request merged behind `main`, so its `CHANGELOG.md` lacks the commits `main` gained.
|
|
21
21
|
The refusal says to open a `chore: cancel the unpublished <version>` pull request that returns `package.json` to the last tag's version and commits what `bun run build` then writes to `CHANGELOG.md`.
|
|
22
|
-
The build reads the returned version as a revert, so it drops the unpublished section
|
|
22
|
+
The build reads the returned version as a revert, so it drops the unpublished section.
|
|
23
|
+
The next run of the workflow that tags it then cuts the release again with every change since the last tag.
|
|
23
24
|
|
|
24
25
|
## What it reads
|
|
25
26
|
|
|
26
27
|
It reads the subject of `HEAD` and `package.json` from the checkout, and the tag from `origin` with `git ls-remote`.
|
|
28
|
+
It names the workflow it runs in by `GITHUB_WORKFLOW`, or as `release-pr` when that is unset.
|
|
27
29
|
The build it runs reads the whole history, so the checkout fetches all of it.
|
|
28
30
|
It pushes the tag with the credentials the checkout holds.
|
|
29
31
|
It calls the GitHub API through `gh api`, which takes the repository from the checkout's remote and the token from `GH_TOKEN`.
|
|
@@ -55,7 +57,7 @@ release-tag: tagged 3f2a9c81d0b4 as v0.4.0, and dispatched release.yml on it
|
|
|
55
57
|
|
|
56
58
|
## When it runs
|
|
57
59
|
|
|
58
|
-
The `tag` job of the
|
|
60
|
+
The `tag` job of the `release-pr` workflow runs it on each push to `main` whose head commit reads as a release, as [checks-release-pr](checks-release-pr.md#running-it-in-ci) shows.
|
|
59
61
|
A package repository needs no `tag` job, since [checks-release-push](checks-release-push.md) tags the release it lands.
|
|
60
62
|
|
|
61
63
|
## Related topics
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"src/delivery/release-commit.ts",
|
|
44
44
|
"src/delivery/release-pr.ts",
|
|
45
45
|
"src/delivery/release-preflight.ts",
|
|
46
|
+
"src/delivery/release-schedule.ts",
|
|
46
47
|
"src/delivery/release-publish.ts",
|
|
47
48
|
"src/delivery/release-push.ts",
|
|
48
49
|
"src/delivery/release-tag.ts",
|
|
@@ -142,6 +143,7 @@
|
|
|
142
143
|
"./tsconfig.effect.json": "./tsconfig.effect.json",
|
|
143
144
|
"./browser-hooks.ts": "./src/quality/browser/hooks.ts",
|
|
144
145
|
"./browser-checks.json": "./src/quality/browser/browser-checks.json",
|
|
146
|
+
"./screen-sizes.ts": "./src/quality/browser/screen-sizes.ts",
|
|
145
147
|
"./oxlint": {
|
|
146
148
|
"types": "./dist/presets/oxlint.d.ts",
|
|
147
149
|
"default": "./dist/presets/oxlint.js"
|
|
@@ -4,7 +4,8 @@ import { defaultBranch, git } from "../core/git.ts";
|
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
|
|
6
6
|
import { findBunPinFaults, formatBunPinReport, readBunPinPolicy } from "./bun-pin.ts";
|
|
7
|
-
import { readShellFiles } from "./mutation-job.ts";
|
|
7
|
+
import { readScheduleFiles, readShellFiles } from "./mutation-job.ts";
|
|
8
|
+
import { findScheduledReleases, formatScheduleReport } from "./release-schedule.ts";
|
|
8
9
|
import { findRunnerFaults, formatRunnerReport, jobRuns, readRunnerConfig } from "./runner-policy.ts";
|
|
9
10
|
import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
10
11
|
import { findMissingWorkflows, formatMissingReport, isRecord, names, workflowJobs, type Workflow } from "./workflow-references.ts";
|
|
@@ -328,7 +329,10 @@ const wiring = Effect.gen(function* () {
|
|
|
328
329
|
if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
|
|
329
330
|
const bunFaults = findBunPinFaults(yield* readBunPinPolicy(root, workflows), workflows);
|
|
330
331
|
if (bunFaults.length > 0) yield* Console.error(formatBunPinReport(bunFaults));
|
|
331
|
-
|
|
332
|
+
const scheduleFiles = yield* readScheduleFiles(root, scripts, runs);
|
|
333
|
+
const scheduled = findScheduledReleases(workflows, { scripts, files: scheduleFiles });
|
|
334
|
+
if (scheduled.length > 0) yield* Console.error(formatScheduleReport(scheduled));
|
|
335
|
+
return gaps.length === 0 && faults.length === 0 && missing.length === 0 && bunFaults.length === 0 && scheduled.length === 0;
|
|
332
336
|
});
|
|
333
337
|
|
|
334
338
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
|
@@ -16,6 +16,8 @@ type Walked = {
|
|
|
16
16
|
readonly files: readonly string[];
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
export type BinCheck = (program: Command) => boolean;
|
|
20
|
+
|
|
19
21
|
type Context = {
|
|
20
22
|
readonly scripts: ReadonlyMap<string, string>;
|
|
21
23
|
readonly files: (file: string) => string | undefined;
|
|
@@ -41,15 +43,32 @@ export function within(dir: string | undefined, path: string): string | undefine
|
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export function runsMutation(run: Run, scripts: ReadonlyMap<string, string>, files: (file: string) => string | undefined): boolean {
|
|
44
|
-
return
|
|
46
|
+
return runsBin(run, scripts, files, mutationBin);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function runsBin(
|
|
50
|
+
run: Run,
|
|
51
|
+
scripts: ReadonlyMap<string, string>,
|
|
52
|
+
files: (file: string) => string | undefined,
|
|
53
|
+
isBin: BinCheck,
|
|
54
|
+
followShellOperands = false,
|
|
55
|
+
): boolean {
|
|
56
|
+
return scriptRunsBin(run.script, run.dir, { scripts, files }, { scripts: [], files: [] }, isBin, followShellOperands);
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
function
|
|
59
|
+
function scriptRunsBin(
|
|
60
|
+
script: string,
|
|
61
|
+
dir: string | undefined,
|
|
62
|
+
context: Context,
|
|
63
|
+
walked: Walked,
|
|
64
|
+
isBin: BinCheck,
|
|
65
|
+
followShellOperands: boolean,
|
|
66
|
+
): boolean {
|
|
48
67
|
let cwd = dir;
|
|
49
68
|
for (const command of shellCommands(script)) {
|
|
50
69
|
const [program = "", ...args] = fromProgram(command);
|
|
51
70
|
if (program === "cd") cwd = changedDirectory(cwd, withoutOptions(args, new Set()));
|
|
52
|
-
else if (
|
|
71
|
+
else if (binCommand(command, cwd, context, walked, isBin, followShellOperands)) return true;
|
|
53
72
|
}
|
|
54
73
|
return false;
|
|
55
74
|
}
|
|
@@ -73,11 +92,16 @@ function fileAt(dir: string | undefined, path: string): string | undefined {
|
|
|
73
92
|
return within(dir, path) || undefined;
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
function invocation(command: Command, dir: string | undefined, context: Context, walked: Walked): Invocation | undefined {
|
|
95
|
+
function invocation(command: Command, dir: string | undefined, context: Context, walked: Walked, followShellOperands: boolean): Invocation | undefined {
|
|
77
96
|
const [first = "", ...rest] = fromProgram(command);
|
|
78
|
-
if (first !== "bun")
|
|
97
|
+
if (first !== "bun") {
|
|
98
|
+
if (followShellOperands && rest[0] !== undefined && !rest[0].startsWith("-") && SHELLS.includes(first.slice(first.lastIndexOf("/") + 1))) {
|
|
99
|
+
return { program: [first, ...rest], file: fileAt(dir, rest[0]), dir };
|
|
100
|
+
}
|
|
101
|
+
return { program: [first, ...rest], file: first.includes("/") ? fileAt(dir, first) : undefined, dir };
|
|
102
|
+
}
|
|
79
103
|
const [subcommand = "", ...operands] = withoutOptions(rest, BUN_OPERANDS);
|
|
80
|
-
if (subcommand === "x") return invocation(["bunx", ...operands], dir, context, walked);
|
|
104
|
+
if (subcommand === "x") return invocation(["bunx", ...operands], dir, context, walked, followShellOperands);
|
|
81
105
|
if (BUN_COMMANDS.has(subcommand)) return undefined;
|
|
82
106
|
const [target = "", ...targetArgs] = subcommand === "run" ? withoutOptions(operands, BUN_OPERANDS) : [subcommand, ...operands];
|
|
83
107
|
const cwd = bunDirectory([...bunOptions(rest), ...(subcommand === "run" ? bunOptions(operands) : [])], dir);
|
|
@@ -85,19 +109,33 @@ function invocation(command: Command, dir: string | undefined, context: Context,
|
|
|
85
109
|
return body === undefined ? { program: [target, ...targetArgs], file: fileAt(cwd, target), dir: cwd } : { script: target, body };
|
|
86
110
|
}
|
|
87
111
|
|
|
88
|
-
function
|
|
89
|
-
|
|
112
|
+
function binCommand(
|
|
113
|
+
command: Command,
|
|
114
|
+
dir: string | undefined,
|
|
115
|
+
context: Context,
|
|
116
|
+
walked: Walked,
|
|
117
|
+
isBin: BinCheck,
|
|
118
|
+
followShellOperands: boolean,
|
|
119
|
+
): boolean {
|
|
120
|
+
const call = invocation(command, dir, context, walked, followShellOperands);
|
|
90
121
|
if (call === undefined) return false;
|
|
91
122
|
// bun runs a script from the directory of the package.json that names it.
|
|
92
|
-
if ("body" in call) return
|
|
93
|
-
return
|
|
123
|
+
if ("body" in call) return scriptRunsBin(call.body, ROOT, context, { ...walked, scripts: [...walked.scripts, call.script] }, isBin, followShellOperands);
|
|
124
|
+
return isBin(call.program) || fileRunsBin(call.file, call.dir, context, walked, isBin, followShellOperands);
|
|
94
125
|
}
|
|
95
126
|
|
|
96
127
|
// A script file runs in its caller's directory, not its own.
|
|
97
|
-
function
|
|
128
|
+
function fileRunsBin(
|
|
129
|
+
file: string | undefined,
|
|
130
|
+
dir: string | undefined,
|
|
131
|
+
context: Context,
|
|
132
|
+
walked: Walked,
|
|
133
|
+
isBin: BinCheck,
|
|
134
|
+
followShellOperands: boolean,
|
|
135
|
+
): boolean {
|
|
98
136
|
if (file === undefined || walked.files.includes(file)) return false;
|
|
99
137
|
const body = context.files(file);
|
|
100
|
-
return body !== undefined &&
|
|
138
|
+
return body !== undefined && scriptRunsBin(body, dir, context, { ...walked, files: [...walked.files, file] }, isBin, followShellOperands);
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
function mutationBin([program = "", subcommand]: Command): boolean {
|
|
@@ -115,13 +153,25 @@ function filesWanted(runs: readonly Run[], scripts: ReadonlyMap<string, string>,
|
|
|
115
153
|
return [...wanted];
|
|
116
154
|
}
|
|
117
155
|
|
|
118
|
-
|
|
156
|
+
function scheduleFilesWanted(runs: readonly Run[], scripts: ReadonlyMap<string, string>, files: ReadonlyMap<string, string>, read: ReadonlySet<string>): readonly string[] {
|
|
157
|
+
const wanted = new Set<string>();
|
|
158
|
+
const lookup = (file: string) => {
|
|
159
|
+
if (!read.has(file)) wanted.add(file);
|
|
160
|
+
return files.get(file);
|
|
161
|
+
};
|
|
162
|
+
for (const run of runs) runsBin(run, scripts, lookup, () => false, true);
|
|
163
|
+
return [...wanted];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type FilesWanted = (runs: readonly Run[], scripts: ReadonlyMap<string, string>, files: ReadonlyMap<string, string>, read: ReadonlySet<string>) => readonly string[];
|
|
167
|
+
|
|
168
|
+
const readFiles = Effect.fn("readFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[], wanted: FilesWanted) {
|
|
119
169
|
const fs = yield* FileSystem.FileSystem;
|
|
120
170
|
const path = yield* Path.Path;
|
|
121
171
|
const contents = new Map<string, string>();
|
|
122
172
|
const read = new Set<string>();
|
|
123
|
-
for (let
|
|
124
|
-
for (const file of
|
|
173
|
+
for (let pending = wanted(runs, scripts, contents, read); pending.length > 0; pending = wanted(runs, scripts, contents, read)) {
|
|
174
|
+
for (const file of pending) {
|
|
125
175
|
read.add(file);
|
|
126
176
|
const text = yield* fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => undefined));
|
|
127
177
|
if (text !== undefined && (file.endsWith(".sh") || SHELL_SHEBANG.test(text))) contents.set(file, text);
|
|
@@ -129,3 +179,11 @@ export const readShellFiles = Effect.fn("readShellFiles")(function* (root: strin
|
|
|
129
179
|
}
|
|
130
180
|
return contents;
|
|
131
181
|
});
|
|
182
|
+
|
|
183
|
+
export const readShellFiles = Effect.fn("readShellFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[]) {
|
|
184
|
+
return yield* readFiles(root, scripts, runs, filesWanted);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const readScheduleFiles = Effect.fn("readScheduleFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[]) {
|
|
188
|
+
return yield* readFiles(root, scripts, runs, scheduleFilesWanted);
|
|
189
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
3
|
import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
|
|
4
|
-
import { releaseTitle } from "./release.ts";
|
|
4
|
+
import { releaseTitle, runningWorkflow } from "./release.ts";
|
|
5
5
|
import { buildRelease, commitRelease, hasBranch, MANIFEST, planRelease, pointBranch, versionIn } from "./release-commit.ts";
|
|
6
6
|
import { git } from "../core/git.ts";
|
|
7
7
|
import { runMain, Usage } from "../core/main.ts";
|
|
@@ -13,7 +13,6 @@ type Remote = {
|
|
|
13
13
|
|
|
14
14
|
const NAME = "checks-release-pr";
|
|
15
15
|
const USAGE = "usage: release-pr.ts <workflow>...";
|
|
16
|
-
const UNTAGGED = "rerun the tag job of daily-release on the release commit, whose refusal says what to fix";
|
|
17
16
|
|
|
18
17
|
const PullRequest = Schema.Struct({ number: Schema.Int, title: Schema.String, html_url: Schema.String });
|
|
19
18
|
type PullRequest = typeof PullRequest.Type;
|
|
@@ -53,7 +52,8 @@ const releasePullRequest = Effect.gen(function* () {
|
|
|
53
52
|
const workflows = process.argv.slice(2);
|
|
54
53
|
if (workflows.length === 0) return yield* new Usage({ message: USAGE });
|
|
55
54
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
56
|
-
const
|
|
55
|
+
const untagged = `rerun the tag job of ${yield* runningWorkflow} on the release commit, whose refusal says what to fix`;
|
|
56
|
+
const plan = yield* planRelease("release-pr", root, untagged);
|
|
57
57
|
if (plan === undefined) return true;
|
|
58
58
|
const { base, branch, head, next, bumped } = plan;
|
|
59
59
|
const remote = yield* readRemote(root, branch);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { runsBin } from "./mutation-job.ts";
|
|
2
|
+
import { jobRuns } from "./runner-policy.ts";
|
|
3
|
+
import type { Command } from "./shell-command.ts";
|
|
4
|
+
import { isRecord, locate, names, workflowJobs, type Workflow } from "./workflow-references.ts";
|
|
5
|
+
|
|
6
|
+
export type ScheduledRelease = {
|
|
7
|
+
readonly location: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ScheduleContext = {
|
|
11
|
+
readonly scripts: ReadonlyMap<string, string>;
|
|
12
|
+
readonly files: ReadonlyMap<string, string>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const EMPTY: ScheduleContext = { scripts: new Map(), files: new Map() };
|
|
16
|
+
|
|
17
|
+
const RELEASE_BINS = ["pr", "push", "tag", "publish"].flatMap((bin) => [`checks-release-${bin}`, `release-${bin}.ts`]);
|
|
18
|
+
|
|
19
|
+
function isReleaseBin(word: string): boolean {
|
|
20
|
+
return RELEASE_BINS.some((name) => word === name || word.endsWith(`/${name}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isReleaseCommand(program: Command): boolean {
|
|
24
|
+
const [head = "", ...rest] = program;
|
|
25
|
+
if (head === "npm" && rest[0] === "publish") return true;
|
|
26
|
+
return isReleaseBin(head);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function localUses(job: Readonly<Record<string, unknown>>): string | undefined {
|
|
30
|
+
const uses = job["uses"];
|
|
31
|
+
return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function workflowReleases(
|
|
35
|
+
workflow: Workflow,
|
|
36
|
+
context: ScheduleContext,
|
|
37
|
+
byPath: ReadonlyMap<string, Workflow>,
|
|
38
|
+
walkedWorkflows: readonly string[],
|
|
39
|
+
): boolean {
|
|
40
|
+
const files = (file: string) => context.files.get(file);
|
|
41
|
+
if (
|
|
42
|
+
workflowJobs(workflow)
|
|
43
|
+
.flatMap(([, job]) => jobRuns(workflow, job))
|
|
44
|
+
.some((run) => runsBin(run, context.scripts, files, isReleaseCommand, true))
|
|
45
|
+
)
|
|
46
|
+
return true;
|
|
47
|
+
return workflowJobs(workflow).some(([, job]) => {
|
|
48
|
+
const called = localUses(job);
|
|
49
|
+
if (called === undefined || walkedWorkflows.includes(called)) return false;
|
|
50
|
+
const next = byPath.get(called);
|
|
51
|
+
return next !== undefined && workflowReleases(next, context, byPath, [...walkedWorkflows, called]);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function schedules(document: unknown): boolean {
|
|
56
|
+
const on = isRecord(document) ? document["on"] : undefined;
|
|
57
|
+
return isRecord(on) ? "schedule" in on : (names(on) ?? []).includes("schedule");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findScheduledReleases(workflows: readonly Workflow[], context: ScheduleContext = EMPTY): readonly ScheduledRelease[] {
|
|
61
|
+
const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
|
|
62
|
+
return workflows
|
|
63
|
+
.filter((workflow) => schedules(workflow.document) && workflowReleases(workflow, context, byPath, [workflow.path]))
|
|
64
|
+
.map((workflow) => ({ location: locate(workflow.path, workflow.text.split("\n"))(["on"], "schedule", "on schedule") }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatScheduleReport(scheduled: readonly ScheduledRelease[]): string {
|
|
68
|
+
return [
|
|
69
|
+
`ci-wiring: ${scheduled.length} workflow(s) release on a schedule:`,
|
|
70
|
+
...scheduled.map(({ location }) => ` ${location}: drop the schedule, so a release runs only when someone starts it`),
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
+
import { runningWorkflow } from "./release.ts";
|
|
3
4
|
import { tagHead } from "./release-tagging.ts";
|
|
4
5
|
import { git } from "../core/git.ts";
|
|
5
6
|
import { runMain, Usage } from "../core/main.ts";
|
|
@@ -9,7 +10,7 @@ const USAGE = "usage: release-tag.ts <workflow>";
|
|
|
9
10
|
const releaseTag = Effect.gen(function* () {
|
|
10
11
|
const [workflow, ...extra] = process.argv.slice(2);
|
|
11
12
|
if (workflow === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
12
|
-
return yield* tagHead("release-tag", (yield* git(["rev-parse", "--show-toplevel"])).trim(), workflow,
|
|
13
|
+
return yield* tagHead("release-tag", (yield* git(["rev-parse", "--show-toplevel"])).trim(), workflow, yield* runningWorkflow);
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
if (import.meta.main) runMain("checks-release-tag", releaseTag);
|
package/src/delivery/release.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Schema } from "effect";
|
|
1
|
+
import { Config, Effect, Schema } from "effect";
|
|
2
2
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
3
3
|
import { groupOf } from "./changelog.ts";
|
|
4
4
|
import { git } from "../core/git.ts";
|
|
@@ -40,6 +40,8 @@ export function withVersion(manifest: string, from: string, to: string): string
|
|
|
40
40
|
return field.test(manifest) ? manifest.replace(field, `$1${to}$2`) : undefined;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export const runningWorkflow = Config.String("GITHUB_WORKFLOW").pipe(Config.withDefault("release-pr"));
|
|
44
|
+
|
|
43
45
|
export function releaseTitle(version: string): string {
|
|
44
46
|
return `chore: release ${version}`;
|
|
45
47
|
}
|