@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.
- package/CHANGELOG.md +8 -569
- package/docs/bins.md +5 -0
- package/docs/configs/commit-messages.md +5 -4
- package/docs/design.md +28 -6
- package/docs/gates/checks-ci-wiring.md +1 -1
- package/docs/gates/checks-commit-identity.md +1 -1
- package/docs/gates/checks-commit-title.md +5 -4
- package/docs/gates/checks-copy.md +2 -2
- package/docs/gates/checks-night-run.md +4 -4
- package/docs/gates/checks-pr-text.md +90 -0
- package/docs/gates/checks-release-notes.md +39 -4
- package/docs/gates/checks-release-pr.md +6 -4
- package/docs/gates/checks-release-preflight.md +94 -0
- package/docs/gates/checks-release-publish.md +63 -0
- package/docs/gates/checks-release-push.md +162 -0
- package/docs/gates/checks-release-report.md +2 -1
- package/docs/gates/checks-release-tag.md +2 -0
- package/docs/gates/checks-secrets.md +90 -1
- package/docs/gates/checks-setup.md +76 -0
- package/docs/gates/checks-vendor.md +3 -3
- package/package.json +14 -1
- package/src/delivery/commit-title.ts +14 -7
- package/src/delivery/gitleaks.ts +24 -9
- package/src/delivery/pr-text.ts +77 -0
- package/src/delivery/release-commit.ts +152 -0
- package/src/delivery/release-pr.ts +9 -132
- package/src/delivery/release-preflight.ts +177 -0
- package/src/delivery/release-publish.ts +73 -0
- package/src/delivery/release-push.ts +148 -0
- package/src/delivery/release-tag.ts +4 -53
- package/src/delivery/release-tagging.ts +74 -0
- package/src/delivery/secrets-pack.ts +133 -0
- package/src/delivery/secrets.ts +5 -4
- package/src/delivery/setup.ts +399 -0
- 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,
|
|
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 {
|
|
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
|
|
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*
|
|
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
|
|
173
|
-
if (
|
|
174
|
-
|
|
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);
|