@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 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
- Six `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
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 daily release pull request.
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 daily release also needs the repository setting that lets the token open a pull request.
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, or a setup-bun step drifts from the fleet bun version. |
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 on the daily release's schedule.
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 `daily-release` on the release commit, whose own refusal says what to fix, rather than at a tag pushed by hand.
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 daily release workflow below runs it once a day on the default branch.
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
- Run the workflow by hand with `gh workflow run daily-release` to refresh the release pull request sooner.
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 daily release as the `daily-release` workflow below.
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 on the schedule, and the `tag` job runs when a release commit lands on `main`, as [checks-release-tag](checks-release-tag.md) says:
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: daily-release
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 daily release needs the repository's other workflows to accept the dispatch:
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 daily release plans.
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, and the next `daily-release` run cuts the release again with every change since the last tag.
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 daily release 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.
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.59.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
- return gaps.length === 0 && faults.length === 0 && missing.length === 0 && bunFaults.length === 0;
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 scriptRunsMutation(run.script, run.dir, { scripts, files }, { scripts: [], files: [] });
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 scriptRunsMutation(script: string, dir: string | undefined, context: Context, walked: Walked): boolean {
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 (mutationCommand(command, cwd, context, walked)) return true;
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") return { program: [first, ...rest], file: first.includes("/") ? fileAt(dir, first) : undefined, dir };
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 mutationCommand(command: Command, dir: string | undefined, context: Context, walked: Walked): boolean {
89
- const call = invocation(command, dir, context, walked);
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 scriptRunsMutation(call.body, ROOT, context, { ...walked, scripts: [...walked.scripts, call.script] });
93
- return mutationBin(call.program) || fileRunsMutation(call.file, call.dir, context, walked);
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 fileRunsMutation(file: string | undefined, dir: string | undefined, context: Context, walked: Walked): boolean {
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 && scriptRunsMutation(body, dir, context, { ...walked, files: [...walked.files, file] });
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
- export const readShellFiles = Effect.fn("readShellFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[]) {
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 wanted = filesWanted(runs, scripts, contents, read); wanted.length > 0; wanted = filesWanted(runs, scripts, contents, read)) {
124
- for (const file of wanted) {
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 plan = yield* planRelease("release-pr", root, UNTAGGED);
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, "daily-release");
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);
@@ -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
  }