@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 +16 -0
- package/docs/design.md +3 -2
- package/docs/gates/checks-changelog.md +2 -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 +2 -1
- package/src/delivery/changelog-write.ts +22 -8
- package/src/delivery/changelog.ts +6 -5
- 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.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
|
|
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
|
|
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,
|
|
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.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 }) =>
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
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
|
}
|