@avi2dg/checks 0.52.1 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +8 -569
  2. package/docs/bins.md +5 -0
  3. package/docs/configs/commit-messages.md +5 -4
  4. package/docs/design.md +28 -6
  5. package/docs/gates/checks-ci-wiring.md +1 -1
  6. package/docs/gates/checks-commit-identity.md +1 -1
  7. package/docs/gates/checks-commit-title.md +5 -4
  8. package/docs/gates/checks-copy.md +2 -2
  9. package/docs/gates/checks-night-run.md +4 -4
  10. package/docs/gates/checks-pr-text.md +90 -0
  11. package/docs/gates/checks-release-notes.md +39 -4
  12. package/docs/gates/checks-release-pr.md +6 -4
  13. package/docs/gates/checks-release-preflight.md +94 -0
  14. package/docs/gates/checks-release-publish.md +63 -0
  15. package/docs/gates/checks-release-push.md +162 -0
  16. package/docs/gates/checks-release-report.md +2 -1
  17. package/docs/gates/checks-release-tag.md +2 -0
  18. package/docs/gates/checks-secrets.md +90 -1
  19. package/docs/gates/checks-setup.md +76 -0
  20. package/docs/gates/checks-vendor.md +3 -3
  21. package/package.json +14 -1
  22. package/src/delivery/commit-title.ts +14 -7
  23. package/src/delivery/gitleaks.ts +24 -9
  24. package/src/delivery/pr-text.ts +77 -0
  25. package/src/delivery/release-commit.ts +152 -0
  26. package/src/delivery/release-pr.ts +9 -132
  27. package/src/delivery/release-preflight.ts +177 -0
  28. package/src/delivery/release-publish.ts +73 -0
  29. package/src/delivery/release-push.ts +148 -0
  30. package/src/delivery/release-tag.ts +4 -53
  31. package/src/delivery/release-tagging.ts +74 -0
  32. package/src/delivery/secrets-pack.ts +133 -0
  33. package/src/delivery/secrets.ts +5 -4
  34. package/src/delivery/setup.ts +399 -0
  35. package/src/delivery/workflow-references.ts +2 -2
@@ -0,0 +1,152 @@
1
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
+ import * as Base64 from "effect/encoding/Base64";
3
+ import { gitHubJson, REPOSITORY } from "./github.ts";
4
+ import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
5
+ import { git } from "../core/git.ts";
6
+
7
+ export class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ type Staged =
12
+ | { readonly kind: "written"; readonly path: string; readonly mode: string }
13
+ | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
14
+
15
+ type Read =
16
+ | { readonly kind: "written"; readonly path: string; readonly mode: string; readonly content: string }
17
+ | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
18
+
19
+ type Built = {
20
+ readonly tree: string;
21
+ readonly files: readonly Read[];
22
+ };
23
+
24
+ export const MANIFEST = "package.json";
25
+ const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
26
+ const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
27
+
28
+ const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
29
+ const decodeSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ sha: Schema.String })));
30
+ export const decodeRef = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ ref: Schema.String })));
31
+
32
+ export function releaseBranchOf(base: string): string {
33
+ return `release/${base}`;
34
+ }
35
+
36
+ export function stagedOf(raw: string): readonly Staged[] {
37
+ const fields = raw.split("\0");
38
+ const staged: Staged[] = [];
39
+ for (let index = 0; index + 1 < fields.length; index += 2) {
40
+ const [, from = "", to = "", status] = RAW_ENTRY.exec(fields[index] ?? "") ?? [];
41
+ const path = fields[index + 1] ?? "";
42
+ staged.push(status === "D" ? { kind: "deleted", path, mode: from } : { kind: "written", path, mode: to });
43
+ }
44
+ return staged;
45
+ }
46
+
47
+ export function refused(message: string): ReleaseRefused {
48
+ return new ReleaseRefused({ message });
49
+ }
50
+
51
+ export const versionIn = (manifest: string): Effect.Effect<string | undefined> =>
52
+ decodeVersion(manifest).pipe(
53
+ Effect.map(({ version }) => version),
54
+ Effect.orElseSucceed(() => undefined),
55
+ );
56
+
57
+ const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending, untagged: string) {
58
+ const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
59
+ if (since !== undefined && since !== `v${version}`) {
60
+ return yield* refused(
61
+ `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; ${untagged}, or return its version to ${since.slice(1)}`,
62
+ );
63
+ }
64
+ const next = nextVersion(version, unreleased);
65
+ if (next === undefined) return yield* refused(`${MANIFEST} holds ${version}, which is no plain major.minor.patch version`);
66
+ const bumped = withVersion(manifest, version, next);
67
+ if (bumped === undefined || (yield* versionIn(bumped)) !== next) {
68
+ return yield* refused(`cannot rewrite the version ${version} in ${MANIFEST} as ${next}`);
69
+ }
70
+ return { next, bumped };
71
+ });
72
+
73
+ const currentBranch = Effect.fn("currentBranch")(function* (root: string) {
74
+ const branch = yield* git(["symbolic-ref", "--quiet", "--short", "HEAD"], root).pipe(
75
+ Effect.map((name) => name.trim()),
76
+ Effect.catchTag("GitFailure", () => Effect.succeed("")),
77
+ );
78
+ if (branch === "") return yield* refused("HEAD is detached; check out the branch the release goes to");
79
+ return branch;
80
+ });
81
+
82
+ export const hasBranch = Effect.fn("hasBranch")(function* (root: string, branch: string) {
83
+ return (yield* git(["ls-remote", "origin", `refs/heads/${branch}`], root)).trim() !== "";
84
+ });
85
+
86
+ type Plan = {
87
+ readonly base: string;
88
+ readonly branch: string;
89
+ readonly head: string;
90
+ readonly next: string;
91
+ readonly bumped: string;
92
+ };
93
+
94
+ // Undefined means nothing is unreleased, which it has already said.
95
+ export const planRelease = Effect.fn("planRelease")(function* (label: string, root: string, untagged: string) {
96
+ const pending = yield* readPending(root);
97
+ if (pending.unreleased.length === 0) {
98
+ yield* Console.log(`${label}: no unreleased changes ${pending.since === undefined ? "with no tag yet" : `since ${pending.since}`}`);
99
+ return undefined;
100
+ }
101
+ const base = yield* currentBranch(root);
102
+ const head = (yield* git(["rev-parse", "HEAD"], root)).trim();
103
+ const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
104
+ const { next, bumped } = yield* releaseVersion(manifest, pending, untagged);
105
+ return { base, branch: releaseBranchOf(base), head, next, bumped } satisfies Plan;
106
+ });
107
+
108
+ const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Staged) {
109
+ if (staged.kind === "deleted") return staged;
110
+ if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
111
+ const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
112
+ return { ...staged, content: Base64.encode(bytes) };
113
+ });
114
+
115
+ // The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
116
+ export const buildRelease = Effect.fn("buildRelease")(function* (name: string, root: string, bumped: string) {
117
+ if ((yield* git(["status", "--porcelain", "--untracked-files=no"], root)).trim() !== "") {
118
+ return yield* refused("the working tree has changes to tracked files; the release commits only what the build writes");
119
+ }
120
+ const restore = git(["reset", "--quiet", "--hard", "HEAD"], root).pipe(
121
+ Effect.catchTag("GitFailure", (failure) => Console.error(`${name}: cannot restore the working tree: ${failure.message}`)),
122
+ );
123
+ return yield* Effect.gen(function* () {
124
+ yield* (yield* FileSystem.FileSystem).writeFileString((yield* Path.Path).join(root, MANIFEST), bumped);
125
+ yield* runBuild(root);
126
+ yield* git(["add", "--update"], root);
127
+ const tree = (yield* git(["write-tree"], root)).trim();
128
+ const staged = stagedOf(yield* git(["diff", "--cached", "--raw", "-z", "--no-renames", "HEAD"], root));
129
+ return { tree, files: yield* Effect.forEach(staged, (one) => readStaged(root, one)) } satisfies Built;
130
+ }).pipe(Effect.ensuring(restore));
131
+ });
132
+
133
+ const treeEntry = Effect.fn("treeEntry")(function* (file: Read) {
134
+ if (file.kind === "deleted") return { path: file.path, mode: file.mode, type: "blob", sha: null };
135
+ const { sha } = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/blobs`, { content: file.content, encoding: "base64" });
136
+ return { path: file.path, mode: file.mode, type: "blob", sha };
137
+ });
138
+
139
+ export const commitRelease = Effect.fn("commitRelease")(function* (root: string, head: string, built: Built, version: string) {
140
+ const tree = yield* Effect.forEach(built.files, treeEntry);
141
+ const baseTree = (yield* git(["rev-parse", `${head}^{tree}`], root)).trim();
142
+ const made = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/trees`, { base_tree: baseTree, tree });
143
+ if (made.sha !== built.tree) return yield* refused(`GitHub built the tree ${made.sha}, but the build wrote ${built.tree}`);
144
+ const commit = { message: releaseTitle(version), tree: made.sha, parents: [head] };
145
+ return (yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/commits`, commit)).sha;
146
+ });
147
+
148
+ export const pointBranch = Effect.fn("pointBranch")(function* (branch: string, sha: string, exists: boolean) {
149
+ yield* exists
150
+ ? gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${branch}`, { sha, force: true })
151
+ : gitHubJson(decodeRef, "POST", `${REPOSITORY}/git/refs`, { ref: `refs/heads/${branch}`, sha });
152
+ });
@@ -1,28 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import * as Base64 from "effect/encoding/Base64";
2
+ import { Console, Effect, Schema } from "effect";
4
3
  import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
5
- import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
4
+ import { releaseTitle } from "./release.ts";
5
+ import { buildRelease, commitRelease, hasBranch, MANIFEST, planRelease, pointBranch, versionIn } from "./release-commit.ts";
6
6
  import { git } from "../core/git.ts";
7
7
  import { runMain, Usage } from "../core/main.ts";
8
8
 
9
- class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
10
- message: Schema.String,
11
- }) {}
12
-
13
- type Staged =
14
- | { readonly kind: "written"; readonly path: string; readonly mode: string }
15
- | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
16
-
17
- type Read =
18
- | { readonly kind: "written"; readonly path: string; readonly mode: string; readonly content: string }
19
- | { readonly kind: "deleted"; readonly path: string; readonly mode: string };
20
-
21
- type Built = {
22
- readonly tree: string;
23
- readonly files: readonly Read[];
24
- };
25
-
26
9
  type Remote = {
27
10
  readonly parents: readonly string[];
28
11
  readonly version: string | undefined;
@@ -30,68 +13,15 @@ type Remote = {
30
13
 
31
14
  const NAME = "checks-release-pr";
32
15
  const USAGE = "usage: release-pr.ts <workflow>...";
33
- const MANIFEST = "package.json";
34
- const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
35
- const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
16
+ const UNTAGGED = "rerun the tag job of daily-release on the release commit, whose refusal says what to fix";
36
17
 
37
- const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
38
- const decodeSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ sha: Schema.String })));
39
- const decodeRef = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ ref: Schema.String })));
40
18
  const PullRequest = Schema.Struct({ number: Schema.Int, title: Schema.String, html_url: Schema.String });
41
19
  type PullRequest = typeof PullRequest.Type;
42
20
  const decodePullRequest = Schema.decodeUnknownEffect(Schema.fromJsonString(PullRequest));
43
21
  const decodePullRequests = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(PullRequest)));
44
22
 
45
- export function releaseBranchOf(base: string): string {
46
- return `release/${base}`;
47
- }
48
-
49
- export function stagedOf(raw: string): readonly Staged[] {
50
- const fields = raw.split("\0");
51
- const staged: Staged[] = [];
52
- for (let index = 0; index + 1 < fields.length; index += 2) {
53
- const [, from = "", to = "", status] = RAW_ENTRY.exec(fields[index] ?? "") ?? [];
54
- const path = fields[index + 1] ?? "";
55
- staged.push(status === "D" ? { kind: "deleted", path, mode: from } : { kind: "written", path, mode: to });
56
- }
57
- return staged;
58
- }
59
-
60
- const refused = (message: string) => new ReleaseRefused({ message });
61
-
62
- const versionIn = (manifest: string) =>
63
- decodeVersion(manifest).pipe(
64
- Effect.map(({ version }) => version),
65
- Effect.orElseSucceed(() => undefined),
66
- );
67
-
68
- const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending) {
69
- const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
70
- if (since !== undefined && since !== `v${version}`) {
71
- return yield* refused(
72
- `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; rerun the tag job of daily-release on the release commit, whose refusal says what to fix, or return its version to ${since.slice(1)}`,
73
- );
74
- }
75
- const next = nextVersion(version, unreleased);
76
- if (next === undefined) return yield* refused(`${MANIFEST} holds ${version}, which is no plain major.minor.patch version`);
77
- const bumped = withVersion(manifest, version, next);
78
- if (bumped === undefined || (yield* versionIn(bumped)) !== next) {
79
- return yield* refused(`cannot rewrite the version ${version} in ${MANIFEST} as ${next}`);
80
- }
81
- return { next, bumped };
82
- });
83
-
84
- const currentBranch = Effect.fn("currentBranch")(function* (root: string) {
85
- const branch = yield* git(["symbolic-ref", "--quiet", "--short", "HEAD"], root).pipe(
86
- Effect.map((name) => name.trim()),
87
- Effect.catchTag("GitFailure", () => Effect.succeed("")),
88
- );
89
- if (branch === "") return yield* refused("HEAD is detached; check out the branch the release goes to");
90
- return branch;
91
- });
92
-
93
23
  const readRemote = Effect.fn("readRemote")(function* (root: string, branch: string) {
94
- if ((yield* git(["ls-remote", "origin", `refs/heads/${branch}`], root)).trim() === "") return undefined;
24
+ if (!(yield* hasBranch(root, branch))) return undefined;
95
25
  const tracking = `refs/remotes/origin/${branch}`;
96
26
  yield* git(["fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${branch}:${tracking}`], root);
97
27
  const [, ...parents] = (yield* git(["rev-list", "--parents", "-n", "1", tracking], root)).trim().split(" ");
@@ -102,52 +32,6 @@ const readRemote = Effect.fn("readRemote")(function* (root: string, branch: stri
102
32
  return { parents, version } satisfies Remote;
103
33
  });
104
34
 
105
- const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Staged) {
106
- if (staged.kind === "deleted") return staged;
107
- if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
108
- const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
109
- return { ...staged, content: Base64.encode(bytes) };
110
- });
111
-
112
- // The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
113
- const buildRelease = Effect.fn("buildRelease")(function* (root: string, bumped: string) {
114
- if ((yield* git(["status", "--porcelain", "--untracked-files=no"], root)).trim() !== "") {
115
- return yield* refused("the working tree has changes to tracked files; the release commits only what the build writes");
116
- }
117
- const restore = git(["reset", "--quiet", "--hard", "HEAD"], root).pipe(
118
- Effect.catchTag("GitFailure", (failure) => Console.error(`${NAME}: cannot restore the working tree: ${failure.message}`)),
119
- );
120
- return yield* Effect.gen(function* () {
121
- yield* (yield* FileSystem.FileSystem).writeFileString((yield* Path.Path).join(root, MANIFEST), bumped);
122
- yield* runBuild(root);
123
- yield* git(["add", "--update"], root);
124
- const tree = (yield* git(["write-tree"], root)).trim();
125
- const staged = stagedOf(yield* git(["diff", "--cached", "--raw", "-z", "--no-renames", "HEAD"], root));
126
- return { tree, files: yield* Effect.forEach(staged, (one) => readStaged(root, one)) } satisfies Built;
127
- }).pipe(Effect.ensuring(restore));
128
- });
129
-
130
- const treeEntry = Effect.fn("treeEntry")(function* (file: Read) {
131
- if (file.kind === "deleted") return { path: file.path, mode: file.mode, type: "blob", sha: null };
132
- const { sha } = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/blobs`, { content: file.content, encoding: "base64" });
133
- return { path: file.path, mode: file.mode, type: "blob", sha };
134
- });
135
-
136
- const commitRelease = Effect.fn("commitRelease")(function* (root: string, head: string, built: Built, version: string) {
137
- const tree = yield* Effect.forEach(built.files, treeEntry);
138
- const baseTree = (yield* git(["rev-parse", `${head}^{tree}`], root)).trim();
139
- const made = yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/trees`, { base_tree: baseTree, tree });
140
- if (made.sha !== built.tree) return yield* refused(`GitHub built the tree ${made.sha}, but the build wrote ${built.tree}`);
141
- const commit = { message: releaseTitle(version), tree: made.sha, parents: [head] };
142
- return (yield* gitHubJson(decodeSha, "POST", `${REPOSITORY}/git/commits`, commit)).sha;
143
- });
144
-
145
- const pointBranch = Effect.fn("pointBranch")(function* (branch: string, sha: string, exists: boolean) {
146
- yield* exists
147
- ? gitHubJson(decodeRef, "PATCH", `${REPOSITORY}/git/refs/heads/${branch}`, { sha, force: true })
148
- : gitHubJson(decodeRef, "POST", `${REPOSITORY}/git/refs`, { ref: `refs/heads/${branch}`, sha });
149
- });
150
-
151
35
  const findOpen = Effect.fn("findOpen")(function* (base: string, branch: string) {
152
36
  const query = `head={owner}:${encodeURIComponent(branch)}&base=${encodeURIComponent(base)}&state=open`;
153
37
  const [open] = yield* gitHubJson(decodePullRequests, "GET", `${REPOSITORY}/pulls?${query}`);
@@ -169,16 +53,9 @@ const releasePullRequest = Effect.gen(function* () {
169
53
  const workflows = process.argv.slice(2);
170
54
  if (workflows.length === 0) return yield* new Usage({ message: USAGE });
171
55
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
172
- const pending = yield* readPending(root);
173
- if (pending.unreleased.length === 0) {
174
- yield* Console.log(`release-pr: no unreleased changes ${pending.since === undefined ? "with no tag yet" : `since ${pending.since}`}`);
175
- return true;
176
- }
177
- const base = yield* currentBranch(root);
178
- const branch = releaseBranchOf(base);
179
- const head = (yield* git(["rev-parse", "HEAD"], root)).trim();
180
- const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
181
- const { next, bumped } = yield* releaseVersion(manifest, pending);
56
+ const plan = yield* planRelease("release-pr", root, UNTAGGED);
57
+ if (plan === undefined) return true;
58
+ const { base, branch, head, next, bumped } = plan;
182
59
  const remote = yield* readRemote(root, branch);
183
60
  const current = remote?.version === next && remote.parents.length === 1 && remote.parents[0] === head;
184
61
  const open = yield* findOpen(base, branch);
@@ -186,7 +63,7 @@ const releasePullRequest = Effect.gen(function* () {
186
63
  yield* Console.log(`release-pr: ${open.html_url} releases ${next} from ${head.slice(0, 12)} and is current`);
187
64
  return true;
188
65
  }
189
- if (!current) yield* pointBranch(branch, yield* commitRelease(root, head, yield* buildRelease(root, bumped), next), remote !== undefined);
66
+ if (!current) yield* pointBranch(branch, yield* commitRelease(root, head, yield* buildRelease(NAME, root, bumped), next), remote !== undefined);
190
67
  const { verb, url } = yield* titlePullRequest(open, base, branch, next);
191
68
  yield* Effect.forEach(workflows, (workflow) => dispatch(workflow, branch));
192
69
  yield* Console.log(`release-pr: ${verb} ${url} to release ${next}, and dispatched ${workflows.join(", ")} on ${branch}`);
@@ -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);
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Option, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
+ import { collect } from "../core/git.ts";
5
+ import { runMain, Usage } from "../core/main.ts";
6
+
7
+ const NAME = "checks-release-publish";
8
+ const LABEL = "release-publish";
9
+ const USAGE = "usage: release-publish.ts";
10
+ const MANIFEST = "package.json";
11
+
12
+ class PublishFailed extends Schema.TaggedError<PublishFailed>()("PublishFailed", {
13
+ message: Schema.String,
14
+ }) {}
15
+
16
+ const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.String, version: Schema.String })));
17
+ const decodeTags = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ latest: Schema.String })));
18
+ const decodeMissing = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Literal("E404") }) })));
19
+
20
+ type Standing = "unpublished" | "published" | "superseded";
21
+
22
+ // npm refuses to move `latest` back to a lower version, so a version below it can only stay off npm.
23
+ function standingOf(version: string, latest: string | undefined): Standing {
24
+ if (latest === undefined) return "unpublished";
25
+ const order = Bun.semver.order(version, latest);
26
+ if (order === 0) return "published";
27
+ return order < 0 ? "superseded" : "unpublished";
28
+ }
29
+
30
+ const latestOnNpm = Effect.fn("latestOnNpm")(function* (name: string) {
31
+ const failed = (reason: string) => new PublishFailed({ message: `npm view ${name} dist-tags: ${reason.trim()}` });
32
+ const { stdout, stderr, exitCode } = yield* collect("npm", ["view", name, "dist-tags", "--json"]).pipe(Effect.mapError((cause) => failed(cause.message)));
33
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return Option.isSome(decodeMissing(stdout)) ? undefined : yield* failed(stderr);
34
+ return (yield* decodeTags(stdout).pipe(Effect.mapError((cause) => failed(cause.message)))).latest;
35
+ });
36
+
37
+ const npmPublish = Effect.fn("npmPublish")(
38
+ function* () {
39
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
40
+ const handle = yield* spawner.spawn(ChildProcess.make("npm", ["publish"], { stdin: "ignore", stdout: "inherit", stderr: "inherit" }));
41
+ return (yield* handle.exitCode) === ChildProcessSpawner.ExitCode(0);
42
+ },
43
+ Effect.scoped,
44
+ );
45
+
46
+ const settled = (name: string, version: string, standing: Standing, latest: string | undefined): string =>
47
+ standing === "published"
48
+ ? `${LABEL}: npm already holds ${name}@${version}`
49
+ : `${LABEL}: npm already holds ${name}@${latest ?? ""}, which carries every change in ${version}, so ${version} stays off npm`;
50
+
51
+ const releasePublish = Effect.gen(function* () {
52
+ if (process.argv.length > 2) return yield* new Usage({ message: USAGE });
53
+ const manifest = yield* (yield* FileSystem.FileSystem).readFileString(MANIFEST);
54
+ const { name, version } = yield* decodeManifest(manifest).pipe(Effect.mapError((cause) => new PublishFailed({ message: `${MANIFEST}: ${cause.message}` })));
55
+ const before = yield* latestOnNpm(name);
56
+ const standing = standingOf(version, before);
57
+ if (standing !== "unpublished") {
58
+ yield* Console.log(settled(name, version, standing, before));
59
+ return true;
60
+ }
61
+ if (yield* npmPublish()) {
62
+ yield* Console.log(`${LABEL}: published ${name}@${version}`);
63
+ return true;
64
+ }
65
+ // A release that published while this one ran makes npm refuse it, which leaves nothing to publish.
66
+ const after = yield* latestOnNpm(name);
67
+ const now = standingOf(version, after);
68
+ if (now === "unpublished") return yield* new PublishFailed({ message: `npm publish refused ${name}@${version}` });
69
+ yield* Console.log(settled(name, version, now, after));
70
+ return true;
71
+ });
72
+
73
+ if (import.meta.main) runMain(NAME, releasePublish);