@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 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.53.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);