@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 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 must be on `PATH`, as they are under a package script.
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 either is not on `PATH` or a config does not parse |
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 the fix is npm dist-tag add <package>@<newer version> latest.
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 the fix is npm dist-tag add <package>@<newer version> latest.
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.53.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("jscpd", [...flags, ...thresholds], tree).pipe(
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