@avi2dg/checks 0.60.0 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1
6
+
7
+ Released 2026-11-02.
8
+
9
+ ### Fixes
10
+
11
+ - **changelog:** keep a release's section when its commit gets its tag [#23](https://github.com/avi2d/checks/pull/23)
12
+
13
+ ## 0.61.0
14
+
15
+ Released 2026-11-01.
16
+
17
+ ### Features
18
+
19
+ - **ci-wiring:** refuse a scheduled release in every repository [#22](https://github.com/avi2d/checks/pull/22)
20
+
5
21
  ## 0.60.0
6
22
 
7
23
  Released 2026-10-31.
package/docs/design.md CHANGED
@@ -202,7 +202,8 @@ A daily release pull request would leave each merged change unpublished until a
202
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.
203
203
  Only then does it move `main` to it without a force, so `main` only ever gains a release commit whose checks passed.
204
204
  It tags the commit it landed and nothing else.
205
- 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.
206
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.
207
208
  `checks-release-tag` writes only the `v*` tag of a release commit that already landed.
208
209
  The `github-release` job's `contents: write` creates or updates the GitHub release from the tag's `CHANGELOG.md` section.
@@ -218,7 +219,7 @@ So each dispatched job reports its result as a commit status named for the job,
218
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.
219
220
  The cost is a `workflow_dispatch` trigger and a status step on each workflow a release needs.
220
221
  The title lint reads its title from the open pull request, or lints the head subject when none is open.
221
- 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.
222
223
 
223
224
  ## The docs gate judges what a change touches
224
225
 
@@ -27,6 +27,8 @@ It finds releases in the version bumps of `package.json` across all of `HEAD` an
27
27
  It reads a commit whose `package.json` is missing or has no version as unversioned, so the commit that adds the version is the first release and a repository that adopts a version late still writes its changelog.
28
28
  A bump to a version no newer than the release before it is a revert.
29
29
  A revert cancels every release above the version it returns to, except a release a `v` tag marks as published.
30
+ A `v` tag publishes only the newest bump of its version that the tagged commit reaches.
31
+ So a version bumped again after a cancel keeps its cancelled bump cancelled, and the tag leaves the changelog unchanged.
30
32
  A version older than the newest in the committed `CHANGELOG.md` and absent from it was never published, so its commits go into the next release.
31
33
  It refuses a shallow checkout, since the releases reach back past its history.
32
34
  It refuses a `package.json` with no repository address, since each entry links its pull request under it.
@@ -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.60.0",
3
+ "version": "0.61.1",
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",
@@ -64,9 +64,26 @@ const readBumps = Effect.fn("readBumps")(function* (root: string) {
64
64
  return bumps;
65
65
  });
66
66
 
67
- const readPublished = Effect.fn("readPublished")(function* (root: string) {
67
+ const reaches = (root: string, ancestor: string, descendant: string) =>
68
+ git(["merge-base", "--is-ancestor", ancestor, descendant], root).pipe(
69
+ Effect.as(true),
70
+ Effect.catchTag("GitFailure", () => Effect.succeed(false)),
71
+ );
72
+
73
+ // A tag publishes the newest bump of its version that it reaches, so a cancelled older bump of that version stays cancelled.
74
+ const readPublishedShas = Effect.fn("readPublishedShas")(function* (root: string, bumps: readonly Bump[]) {
68
75
  const tags = yield* git(["for-each-ref", "--format=%(refname:strip=2)", `refs/tags/${TAG_PREFIX}*`], root);
69
- return new Set(tags.split("\n").flatMap((tag) => (tag.startsWith(TAG_PREFIX) ? [tag.slice(TAG_PREFIX.length)] : [])));
76
+ const published = new Set<string>();
77
+ for (const tag of tags.split("\n").filter((line) => line.startsWith(TAG_PREFIX))) {
78
+ const version = tag.slice(TAG_PREFIX.length);
79
+ for (const bump of bumps.filter((one) => one.version === version).toReversed()) {
80
+ if (yield* reaches(root, bump.sha, `refs/tags/${tag}`)) {
81
+ published.add(bump.sha);
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return published;
70
87
  });
71
88
 
72
89
  const mergedTips = Effect.fn("mergedTips")(function* (root: string, through: string) {
@@ -78,11 +95,7 @@ const mergedTips = Effect.fn("mergedTips")(function* (root: string, through: str
78
95
  for (const [, ...parents] of merges.split("\n").filter((line) => line !== "").map((line) => line.split(" "))) {
79
96
  for (const parent of parents) {
80
97
  // A parent past the bump belongs to a later release, so only a parent beside it joins this one.
81
- const past = yield* git(["merge-base", "--is-ancestor", through, parent], root).pipe(
82
- Effect.as(true),
83
- Effect.catchTag("GitFailure", () => Effect.succeed(false)),
84
- );
85
- if (!past) tips.push(parent);
98
+ if (!(yield* reaches(root, through, parent))) tips.push(parent);
86
99
  }
87
100
  }
88
101
  return tips;
@@ -113,7 +126,8 @@ const write = Effect.gen(function* () {
113
126
  }
114
127
  const recorded = (yield* fs.exists(target)) ? releaseDates(yield* fs.readFileString(target)) : new Map<string, string>();
115
128
  const pending = version === (yield* versionAt(root, "HEAD")) ? undefined : { sha: "HEAD", version, date: yield* today };
116
- const released = cuts(yield* readBumps(root), recorded, yield* readPublished(root), pending);
129
+ const bumps = yield* readBumps(root);
130
+ const released = cuts(bumps, recorded, yield* readPublishedShas(root, bumps), pending);
117
131
  const tip = released.at(-1);
118
132
  const extra = tip === undefined || tip.through === "HEAD" ? [] : yield* mergedTips(root, tip.through);
119
133
  const found = (yield* Effect.forEach(released, (cut, index) => subjectsOf(root, cut, index === released.length - 1 ? extra : [])))
@@ -57,12 +57,13 @@ function entryOf(subject: string, repositoryUrl: string): Entry | undefined {
57
57
  }
58
58
 
59
59
  // A bump not newer than the release before it is a revert: it cancels every release above the version it
60
- // returns to, except a published one.
61
- function standing(bumps: readonly Bump[], published: ReadonlySet<string>): readonly Bump[] {
60
+ // returns to, except a published one. A version cancelled and bumped again holds two bumps, so a bump is
61
+ // published by its sha and never by its version.
62
+ function standing(bumps: readonly Bump[], publishedShas: ReadonlySet<string>): readonly Bump[] {
62
63
  return bumps.reduce<readonly Bump[]>((kept, bump) => {
63
64
  const before = kept.at(-1);
64
65
  if (before === undefined || Bun.semver.order(bump.version, before.version) === 1) return [...kept, bump];
65
- return kept.filter(({ version }) => published.has(version) || Bun.semver.order(version, bump.version) !== 1);
66
+ return kept.filter(({ sha, version }) => publishedShas.has(sha) || Bun.semver.order(version, bump.version) !== 1);
66
67
  }, []);
67
68
  }
68
69
 
@@ -71,11 +72,11 @@ function standing(bumps: readonly Bump[], published: ReadonlySet<string>): reado
71
72
  export function cuts(
72
73
  bumps: readonly Bump[],
73
74
  recorded: ReadonlyMap<string, string>,
74
- published: ReadonlySet<string>,
75
+ publishedShas: ReadonlySet<string>,
75
76
  pending: Bump | undefined,
76
77
  ): readonly Cut[] {
77
78
  const newest = [...recorded.keys()].toSorted(Bun.semver.order).at(-1);
78
- const candidates = standing(pending === undefined ? bumps : [...bumps, pending], published);
79
+ const candidates = standing(pending === undefined ? bumps : [...bumps, pending], publishedShas);
79
80
  const released = candidates.filter(
80
81
  ({ version }, index) =>
81
82
  index === candidates.length - 1 || newest === undefined || recorded.has(version) || Bun.semver.order(version, newest) !== -1,
@@ -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
  }