@avi2dg/checks 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/docs/bins.md +1 -0
- 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-push.md +9 -0
- package/package.json +3 -1
- package/src/delivery/release-preflight.ts +177 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.54.0
|
|
6
|
+
|
|
7
|
+
Released 2026-10-27.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **delivery:** fail fast when the release cannot run [#8](https://github.com/avi2d/checks/pull/8)
|
|
12
|
+
|
|
5
13
|
## 0.53.0
|
|
6
14
|
|
|
7
15
|
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.
|
|
@@ -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)
|
|
@@ -120,12 +120,21 @@ 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
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",
|
|
@@ -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);
|