@avi2dg/checks 0.53.0 → 0.54.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/bins.md +1 -0
- package/docs/gates/checks-lint-coverage.md +2 -2
- package/docs/gates/checks-release-notes.md +33 -0
- package/docs/gates/checks-release-pr.md +3 -0
- package/docs/gates/checks-release-preflight.md +94 -0
- package/docs/gates/checks-release-publish.md +1 -1
- package/docs/gates/checks-release-push.md +10 -1
- package/package.json +3 -1
- package/src/complexity/repetition.ts +10 -1
- package/src/delivery/release-preflight.ts +177 -0
- package/src/quality/lint-coverage.sh +6 -2
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.54.1
|
|
6
|
+
|
|
7
|
+
Released 2026-10-27.
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- resolve the lint gate tools from the repository's own install [#9](https://github.com/avi2d/checks/pull/9)
|
|
12
|
+
|
|
13
|
+
## 0.54.0
|
|
14
|
+
|
|
15
|
+
Released 2026-10-27.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **delivery:** fail fast when the release cannot run [#8](https://github.com/avi2d/checks/pull/8)
|
|
20
|
+
|
|
5
21
|
## 0.53.0
|
|
6
22
|
|
|
7
23
|
Released 2026-10-26.
|
package/docs/bins.md
CHANGED
|
@@ -52,6 +52,7 @@ These bins run on their own:
|
|
|
52
52
|
- [`checks-release-notes`](gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
|
|
53
53
|
- [`checks-release-report`](gates/checks-release-report.md) tells whether the history holds unreleased features or fixes since the last tag.
|
|
54
54
|
- [`checks-release-pr`](gates/checks-release-pr.md) opens or refreshes the pull request that releases the next version, and dispatches its checks.
|
|
55
|
+
- [`checks-release-preflight`](gates/checks-release-preflight.md) refuses the release before it starts when the version disagrees with the last tag or the run lacks the trusted publisher prerequisites.
|
|
55
56
|
- [`checks-release-publish`](gates/checks-release-publish.md) publishes the version `package.json` holds to npm, unless npm already holds it or a newer version.
|
|
56
57
|
- [`checks-release-push`](gates/checks-release-push.md) releases a package's next version onto its branch once the checks pass, tags it and dispatches the release workflow on the tag.
|
|
57
58
|
- [`checks-release-tag`](gates/checks-release-tag.md) tags a landed release commit with its version and dispatches the release workflow on the tag.
|
|
@@ -21,7 +21,7 @@ A `tsconfig.json` that does not extend `@avi2dg/checks/tsconfig.effect.json`, or
|
|
|
21
21
|
It reads the working tree: the `*.ts`, `*.tsx` and `*.astro` files `git ls-files` lists, and the files `oxlint --debug=files` walks.
|
|
22
22
|
It walks without naming a path, since an explicit path bypasses the ignore files whose skips it looks for.
|
|
23
23
|
It reads the program from `tsc --listFilesOnly -p tsconfig.json`, and passes over the program when the repository tracks no `.ts` or `.tsx` file or the root holds no `tsconfig.json`.
|
|
24
|
-
oxlint and tsc
|
|
24
|
+
The gate runs oxlint and tsc from the repository's own `node_modules/.bin`, so they need no `PATH` entry.
|
|
25
25
|
|
|
26
26
|
## Arguments
|
|
27
27
|
|
|
@@ -33,7 +33,7 @@ It takes none.
|
|
|
33
33
|
| --- | --- |
|
|
34
34
|
| 0 | the repository tracks no `.ts`, `.tsx` or `.astro` file, or oxlint walks each one and the program holds both ts-reset rules, the repository tracks no `.ts` or `.tsx` file or the root holds no `tsconfig.json` |
|
|
35
35
|
| 1 | oxlint skips a tracked file, or the program drops a ts-reset rule |
|
|
36
|
-
| 2 | oxlint cannot walk the tree, or tsc cannot list the program after oxlint walks every tracked file, as when
|
|
36
|
+
| 2 | oxlint cannot walk the tree, or tsc cannot list the program after oxlint walks every tracked file, as when the repository's install lacks either tool or a config does not parse |
|
|
37
37
|
|
|
38
38
|
## Sample output
|
|
39
39
|
|
|
@@ -60,7 +60,22 @@ permissions:
|
|
|
60
60
|
contents: read
|
|
61
61
|
id-token: write
|
|
62
62
|
jobs:
|
|
63
|
+
preflight:
|
|
64
|
+
runs-on: ubuntu-latest
|
|
65
|
+
permissions:
|
|
66
|
+
contents: read
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v5
|
|
69
|
+
with:
|
|
70
|
+
fetch-depth: 0
|
|
71
|
+
- uses: oven-sh/setup-bun@v2
|
|
72
|
+
with:
|
|
73
|
+
bun-version-file: .bun-version
|
|
74
|
+
- run: bun install --frozen-lockfile
|
|
75
|
+
- name: fail fast when the release cannot run
|
|
76
|
+
run: ./node_modules/.bin/checks-release-preflight release.yml
|
|
63
77
|
publish:
|
|
78
|
+
needs: preflight
|
|
64
79
|
runs-on: ubuntu-latest
|
|
65
80
|
steps:
|
|
66
81
|
- uses: actions/checkout@v5
|
|
@@ -113,7 +128,22 @@ on:
|
|
|
113
128
|
permissions:
|
|
114
129
|
contents: read
|
|
115
130
|
jobs:
|
|
131
|
+
preflight:
|
|
132
|
+
runs-on: ubuntu-latest
|
|
133
|
+
permissions:
|
|
134
|
+
contents: read
|
|
135
|
+
steps:
|
|
136
|
+
- uses: actions/checkout@v5
|
|
137
|
+
with:
|
|
138
|
+
fetch-depth: 0
|
|
139
|
+
- uses: oven-sh/setup-bun@v2
|
|
140
|
+
with:
|
|
141
|
+
bun-version-file: .bun-version
|
|
142
|
+
- run: bun install --frozen-lockfile
|
|
143
|
+
- name: fail fast when the release cannot run
|
|
144
|
+
run: ./node_modules/.bin/checks-release-preflight release.yml
|
|
116
145
|
github-release:
|
|
146
|
+
needs: preflight
|
|
117
147
|
runs-on: ubuntu-latest
|
|
118
148
|
permissions:
|
|
119
149
|
contents: write
|
|
@@ -139,6 +169,9 @@ jobs:
|
|
|
139
169
|
run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
|
|
140
170
|
```
|
|
141
171
|
|
|
172
|
+
A `preflight` job runs `checks-release-preflight` before the publish step, so a release whose version disagrees with the last tag or whose run lacks the trusted publisher prerequisites fails before the build.
|
|
173
|
+
An untagged bump also fails earlier, since `checks-release-pr` and `checks-release-push` refuse to plan a release the last tag does not name.
|
|
174
|
+
The approval setting has no read only check under the release token, so `checks-release-pr` fails while opening the pull request when the setting is off.
|
|
142
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.
|
|
143
176
|
A release is one commit that holds only the version bump and the built changelog.
|
|
144
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.
|
|
@@ -116,6 +116,8 @@ jobs:
|
|
|
116
116
|
with:
|
|
117
117
|
bun-version-file: .bun-version
|
|
118
118
|
- run: bun install --frozen-lockfile
|
|
119
|
+
- name: fail fast when the release cannot plan
|
|
120
|
+
run: ./node_modules/.bin/checks-release-preflight release.yml
|
|
119
121
|
- name: open or refresh the release pull request
|
|
120
122
|
env:
|
|
121
123
|
GH_TOKEN: ${{ github.token }}
|
|
@@ -141,6 +143,7 @@ jobs:
|
|
|
141
143
|
run: ./node_modules/.bin/checks-release-tag release.yml
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
The `pull-request` job runs `checks-release-preflight` before planning, so a version the last tag does not name fails before `checks-release-pr` builds.
|
|
144
147
|
A public repository keeps `runs-on: ubuntu-latest`.
|
|
145
148
|
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on both jobs, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires of every job but a mutation job.
|
|
146
149
|
A repository whose build needs more than Bun adds the steps its `.github/workflows/ci.yml` runs before `bun run build` to both jobs, and nothing after it.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
audience: consumers
|
|
4
|
+
---
|
|
5
|
+
# checks-release-preflight
|
|
6
|
+
|
|
7
|
+
`checks-release-preflight` fails a release fast when local checks rule it out.
|
|
8
|
+
A version the last tag does not name and a run missing the trusted publisher prerequisites fail before the build.
|
|
9
|
+
A wrong trusted publisher setting on npm itself surfaces only at the publish step, through the publish refusal.
|
|
10
|
+
|
|
11
|
+
## What it checks
|
|
12
|
+
|
|
13
|
+
It reads the version `package.json` holds and the last `v*` tag.
|
|
14
|
+
It refuses when the last tag names another version, since an untagged bump means a release landed that nothing published.
|
|
15
|
+
It reads the release workflow its argument names.
|
|
16
|
+
A workflow that publishes nothing to npm needs no trusted publisher, so that check passes it without reading the repository.
|
|
17
|
+
Only a step whose run command invokes the publish counts as publishing.
|
|
18
|
+
A comment, a job name or any other text that names the publish never counts.
|
|
19
|
+
When the workflow publishes to npm, it compares the repository `package.json` names with the repository the run is in.
|
|
20
|
+
It refuses when the two differ, since the run then cannot present the trusted publisher npm holds.
|
|
21
|
+
It refuses when a publishing job holds no `id-token: write`, since the run then mints no OIDC token for the trusted publisher npm holds.
|
|
22
|
+
A job with its own permissions replaces the workflow permissions, so a workflow grant alone never passes a publishing job that lists its own permissions without the token, and a grant on another job never passes the publishing job.
|
|
23
|
+
The bin checks these prerequisites from local files only.
|
|
24
|
+
The trusted publisher setting itself cannot be verified without an npm credential, so a wrong setting surfaces at the publish step through the publish refusal.
|
|
25
|
+
|
|
26
|
+
## What it reads
|
|
27
|
+
|
|
28
|
+
It reads `package.json` and `.github/workflows/<workflow>` from the checkout.
|
|
29
|
+
It reads the `v*` tags from the checkout, so the checkout fetches the whole history.
|
|
30
|
+
It reads the run repository from `GITHUB_REPOSITORY`.
|
|
31
|
+
It makes no network call and takes no token.
|
|
32
|
+
|
|
33
|
+
## Arguments
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
checks-release-preflight <workflow>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The argument names the release workflow file under `.github/workflows/`, such as `release.yml`.
|
|
40
|
+
|
|
41
|
+
## Exit codes
|
|
42
|
+
|
|
43
|
+
| Code | Result |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| 0 | Every check passes. |
|
|
46
|
+
| 2 | The arguments do not parse, a check refuses the release, or a read fails. |
|
|
47
|
+
|
|
48
|
+
## Sample output
|
|
49
|
+
|
|
50
|
+
A release that agrees on every check prints two lines.
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
release-preflight: package.json holds 0.4.0, which matches v0.4.0
|
|
54
|
+
release-preflight: package.json names acme/widget and release.yml grants the publishing job id-token: write, so the run holds the prerequisites for npm's trusted publisher for @acme/widget
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A manifest bumped past the last tag refuses with the fix.
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
checks-release-preflight: package.json holds 0.7.0, but the last release tag is v0.6.0, so v0.7.0 is untagged; rerun the release that bumped it, whose refusal says what to fix, or return package.json to 0.6.0
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A run in another repository than the manifest names refuses with both sides.
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
checks-release-preflight: package.json names the repository other/widget, but the run is in acme/widget, so the run cannot present npm's trusted publisher for @acme/widget; point repository.url at acme/widget, the repository the run publishes from
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A publishing job with no token refuses with where to grant it.
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
checks-release-preflight: .github/workflows/release.yml grants the publishing job no id-token: write, so the run mints no OIDC token for npm's trusted publisher; grant id-token: write where the release job runs
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## When it runs
|
|
76
|
+
|
|
77
|
+
The release planning boundary runs it before `checks-release-pr` plans and before `checks-release-push` plans a new release, and the `preflight` job of the release workflow keeps it as the prerequisite of the job that publishes.
|
|
78
|
+
A checkout whose `HEAD` already holds the release commit skips it, so `checks-release-push` recovers the missing tag.
|
|
79
|
+
A version the last tag does not name and a run missing the trusted publisher prerequisites then fail before the build, the lint, the typecheck and the suite.
|
|
80
|
+
A wrong trusted publisher setting on npm itself surfaces only at the publish step, through the publish refusal.
|
|
81
|
+
|
|
82
|
+
## Operator check
|
|
83
|
+
|
|
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.
|
|
86
|
+
`checks-release-pr` fails while opening the pull request when the setting is off.
|
|
87
|
+
|
|
88
|
+
## Related topics
|
|
89
|
+
|
|
90
|
+
- [checks-release-notes](checks-release-notes.md)
|
|
91
|
+
- [checks-release-publish](checks-release-publish.md)
|
|
92
|
+
- [checks-release-push](checks-release-push.md)
|
|
93
|
+
- [checks-release-pr](checks-release-pr.md)
|
|
94
|
+
- [checks-release-tag](checks-release-tag.md)
|
|
@@ -21,7 +21,7 @@ Two uploads that overlap can still leave `latest` on the older version, since th
|
|
|
21
21
|
That hold ends early in two cases.
|
|
22
22
|
One is when the 45 minute wait times out.
|
|
23
23
|
The other is when polling fails past the retry bound or the dispatch of the release workflow fails after GitHub accepted it.
|
|
24
|
-
In those cases an unfinished publisher can overlap the next release, and if latest lands on the older version
|
|
24
|
+
In those cases an unfinished publisher can overlap the next release, and if `latest` lands on the older version, `npm dist-tag add <package>@<newer version> latest` repairs it.
|
|
25
25
|
|
|
26
26
|
## What it reads
|
|
27
27
|
|
|
@@ -120,18 +120,27 @@ jobs:
|
|
|
120
120
|
with:
|
|
121
121
|
bun-version-file: .bun-version
|
|
122
122
|
- run: bun install --frozen-lockfile
|
|
123
|
+
- name: fail fast when the release cannot plan
|
|
124
|
+
run: |
|
|
125
|
+
if git log -1 --format=%s HEAD | grep -qE '^chore: release [0-9]+\.[0-9]+\.[0-9]+( \(#[0-9]+\))?$'; then
|
|
126
|
+
echo "HEAD already holds the release commit, so release-push recovers its tag instead of planning"
|
|
127
|
+
else
|
|
128
|
+
./node_modules/.bin/checks-release-preflight release.yml
|
|
129
|
+
fi
|
|
123
130
|
- name: release what main holds once its checks pass
|
|
124
131
|
env:
|
|
125
132
|
GH_TOKEN: ${{ github.token }}
|
|
126
133
|
run: ./node_modules/.bin/checks-release-push release.yml ci.yml
|
|
127
134
|
```
|
|
128
135
|
|
|
136
|
+
The step before it runs `checks-release-preflight`, so a run missing the trusted publisher prerequisites fails before planning.
|
|
137
|
+
A checkout whose `HEAD` already holds the release commit skips that step, so a rerun after a failed tag push recovers the tag.
|
|
129
138
|
The push the workflow token makes starts no run, so landing the release commit never starts the workflow again.
|
|
130
139
|
The concurrency group holds from reading the branch until the release workflow it dispatched finishes, so releases cut by release-push publish one at a time, and a queued run the group cancels had cut nothing.
|
|
131
140
|
That hold ends early in two cases.
|
|
132
141
|
One is when the 45 minute wait times out.
|
|
133
142
|
The other is when polling fails past the retry bound or the dispatch of the release workflow fails after GitHub accepted it.
|
|
134
|
-
In those cases an unfinished publisher can overlap the next release, and if latest lands on the older version
|
|
143
|
+
In those cases an unfinished publisher can overlap the next release, and if `latest` lands on the older version, `npm dist-tag add <package>@<newer version> latest` repairs it.
|
|
135
144
|
[checks-release-publish](checks-release-publish.md) stays the guard for a release workflow dispatched or rerun by hand.
|
|
136
145
|
A run that waits behind it checks out the branch's tip when it starts, so it releases on top of what landed.
|
|
137
146
|
The job waits on the checks it dispatched and on the release workflow, so a hosted runner bills the minutes those workflows take.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.1",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"src/delivery/release.ts",
|
|
42
42
|
"src/delivery/release-commit.ts",
|
|
43
43
|
"src/delivery/release-pr.ts",
|
|
44
|
+
"src/delivery/release-preflight.ts",
|
|
44
45
|
"src/delivery/release-publish.ts",
|
|
45
46
|
"src/delivery/release-push.ts",
|
|
46
47
|
"src/delivery/release-tag.ts",
|
|
@@ -164,6 +165,7 @@
|
|
|
164
165
|
"checks-release-notes": "src/delivery/release-notes.ts",
|
|
165
166
|
"checks-release-report": "src/delivery/release-report.ts",
|
|
166
167
|
"checks-release-pr": "src/delivery/release-pr.ts",
|
|
168
|
+
"checks-release-preflight": "src/delivery/release-preflight.ts",
|
|
167
169
|
"checks-release-publish": "src/delivery/release-publish.ts",
|
|
168
170
|
"checks-release-push": "src/delivery/release-push.ts",
|
|
169
171
|
"checks-release-tag": "src/delivery/release-tag.ts",
|
|
@@ -68,6 +68,15 @@ export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, num
|
|
|
68
68
|
return new Map([...lines].map(([file, covered]) => [file, covered.size]));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const jscpdBinary = Effect.fn("jscpdBinary")(function* () {
|
|
72
|
+
const path = yield* Path.Path;
|
|
73
|
+
const main = yield* Effect.try({
|
|
74
|
+
try: () => new URL("./run-jscpd.js", import.meta.resolve("jscpd/package.json")),
|
|
75
|
+
catch: () => new JscpdUnreadable({ message: "cannot resolve jscpd, a peer dependency of the kit" }),
|
|
76
|
+
});
|
|
77
|
+
return yield* path.fromFileUrl(main);
|
|
78
|
+
});
|
|
79
|
+
|
|
71
80
|
// jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
|
|
72
81
|
// Both ends scan under the head's config, so a changed file set never reads as a change in repetition.
|
|
73
82
|
// jscpd refuses a missing path, so a path in `emptied` the revision lacks is created empty.
|
|
@@ -87,7 +96,7 @@ const scan = Effect.fn("scan")(
|
|
|
87
96
|
const flags = ["--silent", "--no-tips", "--no-gitignore", "--absolute", "--reporters", "json", "--output", output];
|
|
88
97
|
const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
|
|
89
98
|
|
|
90
|
-
const run = yield* collect(
|
|
99
|
+
const run = yield* collect(process.execPath, [yield* jscpdBinary(), ...flags, ...thresholds], tree).pipe(
|
|
91
100
|
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
|
|
92
101
|
);
|
|
93
102
|
const reportPath = path.join(output, REPORT);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
+
import { lastTag } from "./release.ts";
|
|
4
|
+
import { refused } from "./release-commit.ts";
|
|
5
|
+
import { fromProgram, shellWords, type Command } from "./shell-command.ts";
|
|
6
|
+
import { git } from "../core/git.ts";
|
|
7
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
8
|
+
|
|
9
|
+
const LABEL = "release-preflight";
|
|
10
|
+
const USAGE = "usage: release-preflight.ts <release-workflow>";
|
|
11
|
+
const MANIFEST = "package.json";
|
|
12
|
+
const WORKFLOWS = ".github/workflows";
|
|
13
|
+
|
|
14
|
+
const Manifest = Schema.Struct({
|
|
15
|
+
name: Schema.String,
|
|
16
|
+
version: Schema.String,
|
|
17
|
+
repository: Schema.optional(Schema.Union([Schema.String, Schema.Struct({ url: Schema.optional(Schema.String) })])),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
|
|
21
|
+
|
|
22
|
+
const GITHUB_HTTPS = /^(?:git\+)?https:\/\/github\.com\/([^/]+?)\/([^/]+?)(?:\.git)?\/?$/;
|
|
23
|
+
const GITHUB_SCP = /^git@github\.com:([^/]+?)\/([^/]+?)(?:\.git)?\/?$/;
|
|
24
|
+
|
|
25
|
+
export function ownerRepoOf(url: string): string | undefined {
|
|
26
|
+
const match = GITHUB_HTTPS.exec(url) ?? GITHUB_SCP.exec(url);
|
|
27
|
+
return match === null ? undefined : `${match[1]}/${match[2]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PUBLISH_BINS = ["checks-release-publish"];
|
|
31
|
+
const PUBLISH_SOURCES = ["release-publish.ts"];
|
|
32
|
+
|
|
33
|
+
function isPublishPath(word: string, files: readonly string[]): boolean {
|
|
34
|
+
return files.some((file) => word === file || word.endsWith(`/${file}`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function publishesInvocation(words: Command): boolean {
|
|
38
|
+
const [head = "", ...rest] = fromProgram(words);
|
|
39
|
+
if (head === "npm" && rest[0] === "publish") return true;
|
|
40
|
+
if (isPublishPath(head, PUBLISH_BINS)) return true;
|
|
41
|
+
return head === "bun" && rest.some((word) => isPublishPath(word, PUBLISH_SOURCES));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function publishesToNpm(command: string): boolean {
|
|
45
|
+
return shellWords(command).some((words) => publishesInvocation(words.map(({ text }) => text)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const FlexiblePermissions = Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]);
|
|
49
|
+
type FlexiblePermissions = typeof FlexiblePermissions.Type;
|
|
50
|
+
|
|
51
|
+
const WorkflowDocument = Schema.Struct({
|
|
52
|
+
permissions: Schema.optional(FlexiblePermissions),
|
|
53
|
+
jobs: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
54
|
+
});
|
|
55
|
+
const StepDocument = Schema.Struct({ run: Schema.optional(Schema.String) });
|
|
56
|
+
const JobDocument = Schema.Struct({
|
|
57
|
+
permissions: Schema.optional(FlexiblePermissions),
|
|
58
|
+
steps: Schema.optional(Schema.Array(Schema.Unknown)),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const decodeDocument = Schema.decodeUnknownOption(WorkflowDocument);
|
|
62
|
+
const decodeJob = Schema.decodeUnknownOption(JobDocument);
|
|
63
|
+
const decodeStep = Schema.decodeUnknownOption(StepDocument);
|
|
64
|
+
|
|
65
|
+
function grantsOidcValue(permissions: FlexiblePermissions | undefined): boolean {
|
|
66
|
+
return permissions === "write-all" || (typeof permissions === "object" && permissions["id-token"] === "write");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runsOf(job: unknown): string[] {
|
|
70
|
+
const decoded = decodeJob(job);
|
|
71
|
+
if (Option.isNone(decoded) || decoded.value.steps === undefined) return [];
|
|
72
|
+
return decoded.value.steps.flatMap((step) => {
|
|
73
|
+
const decodedStep = decodeStep(step);
|
|
74
|
+
return Option.isNone(decodedStep) || decodedStep.value.run === undefined ? [] : [decodedStep.value.run];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function jobPublishes(job: unknown): boolean {
|
|
79
|
+
return runsOf(job).some(publishesToNpm);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function documentPublishes(document: unknown): boolean {
|
|
83
|
+
const decoded = decodeDocument(document);
|
|
84
|
+
if (Option.isNone(decoded) || decoded.value.jobs === undefined) return false;
|
|
85
|
+
return Object.values(decoded.value.jobs).some(jobPublishes);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function grantsOidc(document: unknown): boolean {
|
|
89
|
+
const decoded = decodeDocument(document);
|
|
90
|
+
if (Option.isNone(decoded)) return false;
|
|
91
|
+
const jobs = decoded.value.jobs;
|
|
92
|
+
if (jobs === undefined) return false;
|
|
93
|
+
const publishing = Object.values(jobs).filter(jobPublishes);
|
|
94
|
+
if (publishing.length === 0) return false;
|
|
95
|
+
return publishing.every((job) => {
|
|
96
|
+
const decodedJob = decodeJob(job);
|
|
97
|
+
if (Option.isNone(decodedJob)) return false;
|
|
98
|
+
const own = decodedJob.value.permissions;
|
|
99
|
+
return grantsOidcValue(own === undefined ? decoded.value.permissions : own);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const readManifest = Effect.fn("readManifest")(function* (root: string) {
|
|
104
|
+
const file = (yield* Path.Path).join(root, MANIFEST);
|
|
105
|
+
return yield* (yield* FileSystem.FileSystem).readFileString(file).pipe(
|
|
106
|
+
Effect.flatMap(decodeManifest),
|
|
107
|
+
Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const checkTag = Effect.fn("checkTag")(function* (root: string) {
|
|
112
|
+
if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
|
|
113
|
+
return yield* refused("the checkout is shallow, so the tag it sees may not be the last one; fetch all of it");
|
|
114
|
+
}
|
|
115
|
+
const { version } = yield* readManifest(root);
|
|
116
|
+
const since = yield* lastTag(root);
|
|
117
|
+
if (since !== undefined && since !== `v${version}`) {
|
|
118
|
+
return yield* refused(
|
|
119
|
+
`${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; rerun the release that bumped it, whose refusal says what to fix, or return ${MANIFEST} to ${since.slice(1)}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
yield* Console.log(`${LABEL}: ${MANIFEST} holds ${version}, which ${since === undefined ? "has no release tag yet" : `matches ${since}`}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const checkPublisher = Effect.fn("checkPublisher")(function* (root: string, workflow: string) {
|
|
126
|
+
const path = (yield* Path.Path).join(root, WORKFLOWS, workflow);
|
|
127
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(path).pipe(
|
|
128
|
+
Effect.mapError(() => refused(`${WORKFLOWS}/${workflow} is missing, so the run cannot show the prerequisites npm's trusted publisher needs`)),
|
|
129
|
+
);
|
|
130
|
+
const document = yield* Effect.try({
|
|
131
|
+
try: () => Bun.YAML.parse(text),
|
|
132
|
+
catch: () => refused(`${WORKFLOWS}/${workflow} does not parse, so the run cannot show the prerequisites npm's trusted publisher needs`),
|
|
133
|
+
});
|
|
134
|
+
if (!documentPublishes(document)) {
|
|
135
|
+
yield* Console.log(`${LABEL}: ${workflow} publishes nothing to npm, so no trusted publisher has to match`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const repository = yield* Config.String("GITHUB_REPOSITORY").pipe(Config.withDefault(""));
|
|
139
|
+
if (repository === "") return yield* refused("GITHUB_REPOSITORY is not set, so the run has no repository to match; run this in the release workflow");
|
|
140
|
+
const { name, repository: field } = yield* readManifest(root);
|
|
141
|
+
const raw = typeof field === "string" ? field : field?.url;
|
|
142
|
+
if (raw === undefined) {
|
|
143
|
+
return yield* refused(
|
|
144
|
+
`${MANIFEST} has no repository, so the run holds no repository for npm's trusted publisher to allow; point repository.url at ${repository}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const held = ownerRepoOf(raw);
|
|
148
|
+
if (held === undefined) {
|
|
149
|
+
return yield* refused(
|
|
150
|
+
`${MANIFEST} names the repository ${raw}, which is no GitHub owner and name, so the run holds no repository for npm's trusted publisher to allow; point repository.url at ${repository}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (held.toLowerCase() !== repository.toLowerCase()) {
|
|
154
|
+
return yield* refused(
|
|
155
|
+
`${MANIFEST} names the repository ${held}, but the run is in ${repository}, so the run cannot present npm's trusted publisher for ${name}; point repository.url at ${repository}, the repository the run publishes from`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (!grantsOidc(document)) {
|
|
159
|
+
return yield* refused(
|
|
160
|
+
`${WORKFLOWS}/${workflow} grants the publishing job no id-token: write, so the run mints no OIDC token for npm's trusted publisher; grant id-token: write where the release job runs`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
yield* Console.log(
|
|
164
|
+
`${LABEL}: ${MANIFEST} names ${repository} and ${workflow} grants the publishing job id-token: write, so the run holds the prerequisites for npm's trusted publisher for ${name}`,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const releasePreflight = Effect.gen(function* () {
|
|
169
|
+
const [workflow, ...extra] = process.argv.slice(2);
|
|
170
|
+
if (workflow === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
171
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
172
|
+
yield* checkTag(root);
|
|
173
|
+
yield* checkPublisher(root, workflow);
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (import.meta.main) runMain("checks-release-preflight", releasePreflight);
|
|
@@ -12,8 +12,12 @@ if ! [ -s "$tmp/expected" ]; then
|
|
|
12
12
|
exit 0
|
|
13
13
|
fi
|
|
14
14
|
|
|
15
|
+
root="$(git rev-parse --show-toplevel)"
|
|
16
|
+
oxlint="$root/node_modules/.bin/oxlint"
|
|
17
|
+
tsc="$root/node_modules/.bin/tsc"
|
|
18
|
+
|
|
15
19
|
# Explicit paths bypass ignore files, so only an unscoped walk proves coverage.
|
|
16
|
-
if ! oxlint --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
|
|
20
|
+
if ! "$oxlint" --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
|
|
17
21
|
echo "lint-coverage: oxlint could not walk the tree:"
|
|
18
22
|
cat "$tmp/walk" "$tmp/walk-error"
|
|
19
23
|
exit 2
|
|
@@ -44,7 +48,7 @@ if ! [ -f tsconfig.json ]; then
|
|
|
44
48
|
exit "$status"
|
|
45
49
|
fi
|
|
46
50
|
|
|
47
|
-
if ! tsc --listFilesOnly -p tsconfig.json > "$tmp/program" 2> "$tmp/program-error"; then
|
|
51
|
+
if ! "$tsc" --listFilesOnly -p tsconfig.json > "$tmp/program" 2> "$tmp/program-error"; then
|
|
48
52
|
echo "lint-coverage: tsc could not list the program tsconfig.json builds:"
|
|
49
53
|
# tsc lists each file of the program as an absolute path beside its errors.
|
|
50
54
|
grep -v '^/' "$tmp/program" || true
|