@catladder/cli 0.0.0-next-883523ef → 0.0.0-next-5b3ef3ac
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/dist/apps/cli/src/catci.js +12 -0
- package/dist/apps/cli/src/catci.js.map +1 -1
- package/dist/apps/cli/src/release/changesetCheck.d.ts +35 -0
- package/dist/apps/cli/src/release/changesetCheck.js +31 -0
- package/dist/apps/cli/src/release/changesetCheck.js.map +1 -0
- package/dist/apps/cli/src/release/changesetCheckJob.d.ts +2 -0
- package/dist/apps/cli/src/release/changesetCheckJob.js +169 -0
- package/dist/apps/cli/src/release/changesetCheckJob.js.map +1 -0
- package/dist/apps/cli/src/release/changesetsReleaseJob.d.ts +2 -0
- package/dist/apps/cli/src/release/changesetsReleaseJob.js +43 -52
- package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
- package/dist/apps/cli/src/release/releaseGit.d.ts +9 -0
- package/dist/apps/cli/src/release/releaseGit.js +49 -0
- package/dist/apps/cli/src/release/releaseGit.js.map +1 -0
- package/dist/bundles/catci/index.js +4 -4
- package/dist/bundles/catenv/index.js +109 -4
- package/dist/bundles/cli/index.js +109 -4
- package/dist/bundles/runner-images/changesets/scripts/changesetCheck +23 -0
- package/dist/bundles/skills/catladder-releases/SKILL.md +31 -2
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js +3 -0
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
- package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.d.ts +7 -0
- package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js +75 -4
- package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js.map +1 -1
- package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.d.ts +26 -4
- package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.js +29 -0
- package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.js.map +1 -1
- package/dist/packages/pipeline/src/release/index.d.ts +12 -0
- package/dist/packages/pipeline/src/release/index.js +2 -0
- package/dist/packages/pipeline/src/release/index.js.map +1 -1
- package/dist/packages/pipeline/src/types/github-types.d.ts +1 -0
- package/dist/runner-images/changesets/scripts/changesetCheck +23 -0
- package/dist/skills/catladder-releases/SKILL.md +31 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/catci.ts +14 -0
- package/src/release/__tests__/changesetCheck.test.ts +69 -0
- package/src/release/changesetCheck.ts +82 -0
- package/src/release/changesetCheckJob.ts +199 -0
- package/src/release/changesetsReleaseJob.ts +36 -48
- package/src/release/releaseGit.ts +47 -0
package/package.json
CHANGED
package/src/catci.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
changesetsReleaseJob,
|
|
24
24
|
dispatchTaggedReleaseWorkflow,
|
|
25
25
|
} from "./release/changesetsReleaseJob";
|
|
26
|
+
import { changesetCheckJob } from "./release/changesetCheckJob";
|
|
26
27
|
import { npmPublishJob, parseNpmPublishArgs } from "./publish/npmPublishJob";
|
|
27
28
|
|
|
28
29
|
const GITLAB_HOST = "https://git.panter.ch";
|
|
@@ -44,6 +45,12 @@ usage:
|
|
|
44
45
|
from the last v* tag, writes the changelog, commits, tags and
|
|
45
46
|
pushes (no-op when no changesets are pending)
|
|
46
47
|
|
|
48
|
+
catci release changeset-check
|
|
49
|
+
MR/PR pipelines: reports which changesets the merge request adds,
|
|
50
|
+
what is pending and what version merging would release (job log,
|
|
51
|
+
changeset-report.md, sticky MR/PR comment where a token allows);
|
|
52
|
+
exits 1 when the merge request adds no changeset
|
|
53
|
+
|
|
47
54
|
catci release dispatch-tagged-workflow <tag>
|
|
48
55
|
github only: dispatches the generated taggedRelease workflow for
|
|
49
56
|
the tag (tags pushed with the job token don't trigger it)
|
|
@@ -139,6 +146,13 @@ const main = async () => {
|
|
|
139
146
|
if (group === "release" && command === "changesets" && args.length === 0) {
|
|
140
147
|
return changesetsReleaseJob();
|
|
141
148
|
}
|
|
149
|
+
if (
|
|
150
|
+
group === "release" &&
|
|
151
|
+
command === "changeset-check" &&
|
|
152
|
+
args.length === 0
|
|
153
|
+
) {
|
|
154
|
+
return changesetCheckJob();
|
|
155
|
+
}
|
|
142
156
|
if (
|
|
143
157
|
group === "release" &&
|
|
144
158
|
command === "dispatch-tagged-workflow" &&
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Changeset } from "../changesets";
|
|
3
|
+
import { CHANGESET_CHECK_MARKER, runChangesetCheck } from "../changesetCheck";
|
|
4
|
+
|
|
5
|
+
const changeset = (bump: Changeset["bump"], summary: string): Changeset => ({
|
|
6
|
+
fileName: `${summary.toLowerCase().replace(/\s+/g, "-")}.md`,
|
|
7
|
+
bump,
|
|
8
|
+
summary,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("runChangesetCheck", () => {
|
|
12
|
+
it("reports an added changeset and the resulting version", () => {
|
|
13
|
+
const result = runChangesetCheck({
|
|
14
|
+
addedFiles: ["fancy-dashboard.md"],
|
|
15
|
+
pending: [changeset("minor", "Add the fancy dashboard")],
|
|
16
|
+
lastTag: "v4.7.0",
|
|
17
|
+
requestLabel: "merge request",
|
|
18
|
+
});
|
|
19
|
+
expect(result.addsChangeset).toBe(true);
|
|
20
|
+
expect(result.markdown).toContain(CHANGESET_CHECK_MARKER);
|
|
21
|
+
expect(result.markdown).toContain("✅ This merge request adds 1 changeset");
|
|
22
|
+
expect(result.markdown).toContain("`fancy-dashboard.md`");
|
|
23
|
+
expect(result.markdown).toContain("**v4.8.0**");
|
|
24
|
+
expect(result.markdown).toContain("(from v4.7.0)");
|
|
25
|
+
expect(result.markdown).toContain("Add the fancy dashboard");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("warns when the merge request adds no changeset", () => {
|
|
29
|
+
const result = runChangesetCheck({
|
|
30
|
+
addedFiles: [],
|
|
31
|
+
pending: [],
|
|
32
|
+
lastTag: "v4.7.0",
|
|
33
|
+
requestLabel: "merge request",
|
|
34
|
+
});
|
|
35
|
+
expect(result.addsChangeset).toBe(false);
|
|
36
|
+
expect(result.markdown).toContain(
|
|
37
|
+
"**This merge request adds no changeset.**",
|
|
38
|
+
);
|
|
39
|
+
expect(result.markdown).toContain("will **not** trigger a release");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("still previews earlier pending changesets when this MR adds none", () => {
|
|
43
|
+
const result = runChangesetCheck({
|
|
44
|
+
addedFiles: [],
|
|
45
|
+
pending: [
|
|
46
|
+
changeset("major", "Remove the legacy mode"),
|
|
47
|
+
changeset("patch", "Fix a leak"),
|
|
48
|
+
],
|
|
49
|
+
lastTag: "v4.7.0",
|
|
50
|
+
requestLabel: "merge request",
|
|
51
|
+
});
|
|
52
|
+
expect(result.addsChangeset).toBe(false);
|
|
53
|
+
expect(result.markdown).toContain("2 changesets are pending");
|
|
54
|
+
expect(result.markdown).toContain("**v5.0.0**");
|
|
55
|
+
expect(result.markdown).toContain("Remove the legacy mode");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("handles the first release (no previous tag)", () => {
|
|
59
|
+
const result = runChangesetCheck({
|
|
60
|
+
addedFiles: ["init.md"],
|
|
61
|
+
pending: [changeset("minor", "Initial feature")],
|
|
62
|
+
lastTag: null,
|
|
63
|
+
requestLabel: "pull request",
|
|
64
|
+
});
|
|
65
|
+
expect(result.markdown).toContain("✅ This pull request adds 1 changeset");
|
|
66
|
+
expect(result.markdown).toContain("**v1.0.0**");
|
|
67
|
+
expect(result.markdown).toContain("(first release)");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pure logic of the changeset check (run in MR/PR pipelines by catci):
|
|
3
|
+
* given the changesets added by the merge request and everything
|
|
4
|
+
* pending on the branch, render the markdown report shown as job
|
|
5
|
+
* output, exposed artifact and sticky MR/PR comment.
|
|
6
|
+
*/
|
|
7
|
+
import type { Changeset } from "./changesets";
|
|
8
|
+
import { getNextVersion, maxBump, renderChangelogEntries } from "./changesets";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* identifies the sticky comment across pushes: the check finds the
|
|
12
|
+
* existing comment carrying this marker and updates it in place
|
|
13
|
+
* instead of posting a new one
|
|
14
|
+
*/
|
|
15
|
+
export const CHANGESET_CHECK_MARKER = "<!-- catladder-changeset-check -->";
|
|
16
|
+
|
|
17
|
+
export type ChangesetCheckInput = {
|
|
18
|
+
/**
|
|
19
|
+
* file names of changesets ADDED by this merge request (relative to
|
|
20
|
+
* `.changeset/`)
|
|
21
|
+
*/
|
|
22
|
+
addedFiles: string[];
|
|
23
|
+
/**
|
|
24
|
+
* all changesets pending on this branch (added here or merged
|
|
25
|
+
* earlier and not yet released)
|
|
26
|
+
*/
|
|
27
|
+
pending: Changeset[];
|
|
28
|
+
lastTag: string | null;
|
|
29
|
+
/** platform wording: "merge request" (gitlab) or "pull request" (github) */
|
|
30
|
+
requestLabel: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ChangesetCheckResult = {
|
|
34
|
+
/** whether this merge request adds at least one changeset */
|
|
35
|
+
addsChangeset: boolean;
|
|
36
|
+
/** the full report, marker included (comment + artifact body) */
|
|
37
|
+
markdown: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const runChangesetCheck = ({
|
|
41
|
+
addedFiles,
|
|
42
|
+
pending,
|
|
43
|
+
lastTag,
|
|
44
|
+
requestLabel,
|
|
45
|
+
}: ChangesetCheckInput): ChangesetCheckResult => {
|
|
46
|
+
const addsChangeset = addedFiles.length > 0;
|
|
47
|
+
|
|
48
|
+
const lines: string[] = [CHANGESET_CHECK_MARKER, "## 🦋 Changeset check", ""];
|
|
49
|
+
|
|
50
|
+
if (addsChangeset) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`✅ This ${requestLabel} adds ${addedFiles.length} changeset${addedFiles.length === 1 ? "" : "s"}: ${addedFiles.map((f) => `\`${f}\``).join(", ")}`,
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
lines.push(
|
|
56
|
+
`⚠️ **This ${requestLabel} adds no changeset.** If the change is user-facing it will be missing from the next release's changelog — add \`.changeset/<name>.md\` (or run \`yarn changeset\`). For docs/chore changes this warning can be ignored.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
lines.push("");
|
|
60
|
+
|
|
61
|
+
if (pending.length === 0) {
|
|
62
|
+
lines.push(
|
|
63
|
+
"No changesets are pending — merging will **not** trigger a release.",
|
|
64
|
+
);
|
|
65
|
+
} else {
|
|
66
|
+
const version = getNextVersion(
|
|
67
|
+
lastTag,
|
|
68
|
+
maxBump(pending.map((c) => c.bump)),
|
|
69
|
+
);
|
|
70
|
+
lines.push(
|
|
71
|
+
`${pending.length} changeset${pending.length === 1 ? " is" : "s are"} pending after merge — the next release will be **v${version}**${lastTag ? ` (from ${lastTag})` : " (first release)"}:`,
|
|
72
|
+
"",
|
|
73
|
+
"<details><summary>Changelog preview</summary>",
|
|
74
|
+
"",
|
|
75
|
+
renderChangelogEntries(pending),
|
|
76
|
+
"",
|
|
77
|
+
"</details>",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { addsChangeset, markdown: lines.join("\n") };
|
|
82
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the changeset check CI job (run by catci via the `changesetCheck`
|
|
3
|
+
* script of the `changesets` job image, in MR/PR pipelines only):
|
|
4
|
+
* reports which changesets the merge request adds, what is pending and
|
|
5
|
+
* what version merging would release. The report goes to the job log,
|
|
6
|
+
* to `changeset-report.md` (exposed as an artifact on gitlab) and to a
|
|
7
|
+
* sticky MR/PR comment where a token allows it. Exits 1 (the job runs
|
|
8
|
+
* with allow_failure) when the merge request adds no changeset.
|
|
9
|
+
*/
|
|
10
|
+
import { writeFile } from "fs/promises";
|
|
11
|
+
import { CHANGESET_CHECK_MARKER, runChangesetCheck } from "./changesetCheck";
|
|
12
|
+
import { readPendingChangesets } from "./changesetsReleaseJob";
|
|
13
|
+
import { ensureReleaseHistory, getLastReleaseTag, git } from "./releaseGit";
|
|
14
|
+
|
|
15
|
+
export const CHANGESET_REPORT_FILE = "changeset-report.md";
|
|
16
|
+
|
|
17
|
+
const onGithub = () => process.env.GITHUB_ACTIONS === "true";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* the changeset files added by this merge request: diffed against the
|
|
21
|
+
* merge base with the target branch (fetched explicitly — CI checkouts
|
|
22
|
+
* don't carry the target branch). Returns null when there is no MR/PR
|
|
23
|
+
* context to diff against.
|
|
24
|
+
*/
|
|
25
|
+
const getAddedChangesetFiles = async (): Promise<string[] | null> => {
|
|
26
|
+
const targetBranch = onGithub()
|
|
27
|
+
? process.env.GITHUB_BASE_REF
|
|
28
|
+
: process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME;
|
|
29
|
+
if (!targetBranch) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
await git("fetch", "--quiet", "origin", targetBranch);
|
|
33
|
+
// FETCH_HEAD instead of origin/<branch>: CI remotes don't always
|
|
34
|
+
// carry the standard branch refspec
|
|
35
|
+
const mergeBase = await git("merge-base", "HEAD", "FETCH_HEAD");
|
|
36
|
+
const diff = await git(
|
|
37
|
+
"diff",
|
|
38
|
+
"--name-only",
|
|
39
|
+
"--diff-filter=A",
|
|
40
|
+
mergeBase,
|
|
41
|
+
"HEAD",
|
|
42
|
+
);
|
|
43
|
+
return diff
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter(
|
|
46
|
+
(file) =>
|
|
47
|
+
file.startsWith(".changeset/") &&
|
|
48
|
+
file.endsWith(".md") &&
|
|
49
|
+
!file.toLowerCase().endsWith("readme.md"),
|
|
50
|
+
)
|
|
51
|
+
.map((file) => file.slice(".changeset/".length));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type StickyComment = { id: number; body?: string };
|
|
55
|
+
|
|
56
|
+
const findSticky = (comments: StickyComment[]): StickyComment | undefined =>
|
|
57
|
+
comments.find((comment) => comment.body?.includes(CHANGESET_CHECK_MARKER));
|
|
58
|
+
|
|
59
|
+
const request = async (
|
|
60
|
+
url: string,
|
|
61
|
+
init: RequestInit,
|
|
62
|
+
): Promise<{ ok: boolean; status: number; json: () => Promise<any> }> => {
|
|
63
|
+
const response = await fetch(url, init);
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${init.method ?? "GET"} ${url} failed: ${response.status} ${await response.text()}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return response;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* sticky PR comment via the workflow's own token
|
|
74
|
+
* (`permissions: pull-requests: write`)
|
|
75
|
+
*/
|
|
76
|
+
const upsertGithubComment = async (markdown: string) => {
|
|
77
|
+
const token = process.env.GITHUB_TOKEN;
|
|
78
|
+
const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
79
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
80
|
+
// pull_request workflows check out refs/pull/<number>/merge
|
|
81
|
+
const prNumber = parseInt(process.env.GITHUB_REF_NAME ?? "");
|
|
82
|
+
if (!token || !repository || !prNumber) {
|
|
83
|
+
console.log("no PR context/token — skipping the PR comment");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const headers = {
|
|
87
|
+
authorization: `Bearer ${token}`,
|
|
88
|
+
accept: "application/vnd.github+json",
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
};
|
|
91
|
+
const comments: StickyComment[] = await (
|
|
92
|
+
await request(
|
|
93
|
+
`${apiUrl}/repos/${repository}/issues/${prNumber}/comments?per_page=100`,
|
|
94
|
+
{ headers },
|
|
95
|
+
)
|
|
96
|
+
).json();
|
|
97
|
+
const existing = findSticky(comments);
|
|
98
|
+
if (existing) {
|
|
99
|
+
await request(
|
|
100
|
+
`${apiUrl}/repos/${repository}/issues/comments/${existing.id}`,
|
|
101
|
+
{
|
|
102
|
+
method: "PATCH",
|
|
103
|
+
headers,
|
|
104
|
+
body: JSON.stringify({ body: markdown }),
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
await request(`${apiUrl}/repos/${repository}/issues/${prNumber}/comments`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers,
|
|
111
|
+
body: JSON.stringify({ body: markdown }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
console.log("updated the sticky PR comment");
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* sticky MR note — only when the project makes GL_TOKEN available to
|
|
119
|
+
* MR pipelines (opt-in: an api-scope token in MR pipelines is a
|
|
120
|
+
* security trade-off). Without it the report is still in the job log
|
|
121
|
+
* and the exposed artifact.
|
|
122
|
+
*/
|
|
123
|
+
const upsertGitlabComment = async (markdown: string) => {
|
|
124
|
+
const token = process.env.GL_TOKEN;
|
|
125
|
+
if (!token) {
|
|
126
|
+
console.log(
|
|
127
|
+
"GL_TOKEN is not available in this pipeline — skipping the MR comment (report available in the job log and the exposed artifact)",
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const apiUrl = process.env.CI_API_V4_URL;
|
|
132
|
+
const projectId = process.env.CI_PROJECT_ID;
|
|
133
|
+
const mrIid = process.env.CI_MERGE_REQUEST_IID;
|
|
134
|
+
if (!apiUrl || !projectId || !mrIid) {
|
|
135
|
+
console.log("no MR context — skipping the MR comment");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const headers = {
|
|
139
|
+
"PRIVATE-TOKEN": token,
|
|
140
|
+
"content-type": "application/json",
|
|
141
|
+
};
|
|
142
|
+
const notesUrl = `${apiUrl}/projects/${projectId}/merge_requests/${mrIid}/notes`;
|
|
143
|
+
const notes: StickyComment[] = await (
|
|
144
|
+
await request(`${notesUrl}?per_page=100`, { headers })
|
|
145
|
+
).json();
|
|
146
|
+
const existing = findSticky(notes);
|
|
147
|
+
if (existing) {
|
|
148
|
+
await request(`${notesUrl}/${existing.id}`, {
|
|
149
|
+
method: "PUT",
|
|
150
|
+
headers,
|
|
151
|
+
body: JSON.stringify({ body: markdown }),
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
await request(notesUrl, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers,
|
|
157
|
+
body: JSON.stringify({ body: markdown }),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
console.log("updated the sticky MR comment");
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const changesetCheckJob = async () => {
|
|
164
|
+
await ensureReleaseHistory();
|
|
165
|
+
const pending = await readPendingChangesets();
|
|
166
|
+
const lastTag = await getLastReleaseTag();
|
|
167
|
+
const addedFiles = await getAddedChangesetFiles();
|
|
168
|
+
|
|
169
|
+
const result = runChangesetCheck({
|
|
170
|
+
addedFiles: addedFiles ?? [],
|
|
171
|
+
pending,
|
|
172
|
+
lastTag,
|
|
173
|
+
requestLabel: onGithub() ? "pull request" : "merge request",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
console.log(result.markdown.replace(`${CHANGESET_CHECK_MARKER}\n`, ""));
|
|
177
|
+
await writeFile(CHANGESET_REPORT_FILE, result.markdown);
|
|
178
|
+
|
|
179
|
+
// a failed comment must not fail the check — the report has already
|
|
180
|
+
// been printed and written
|
|
181
|
+
try {
|
|
182
|
+
if (onGithub()) {
|
|
183
|
+
await upsertGithubComment(result.markdown);
|
|
184
|
+
} else {
|
|
185
|
+
await upsertGitlabComment(result.markdown);
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.warn(`could not update the sticky comment: ${error}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (addedFiles === null) {
|
|
192
|
+
console.log("not running against a merge request — check skipped");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!result.addsChangeset) {
|
|
196
|
+
// exit 1 turns the (allow_failure) job yellow so reviewers notice
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
* from the last `v*` tag, writes the changelog, commits, tags and
|
|
6
6
|
* pushes — the pushed tag triggers the taggedRelease pipeline.
|
|
7
7
|
*/
|
|
8
|
-
import { execFile as execFileCb } from "child_process";
|
|
9
8
|
import { readFile, readdir, rm, writeFile } from "fs/promises";
|
|
10
9
|
import { join } from "path";
|
|
11
|
-
import {
|
|
10
|
+
import type { Changeset } from "./changesets";
|
|
12
11
|
import {
|
|
13
12
|
getNextVersion,
|
|
14
13
|
maxBump,
|
|
@@ -16,8 +15,7 @@ import {
|
|
|
16
15
|
prependToChangelog,
|
|
17
16
|
renderChangelogEntries,
|
|
18
17
|
} from "./changesets";
|
|
19
|
-
|
|
20
|
-
const execFile = promisify(execFileCb);
|
|
18
|
+
import { ensureReleaseHistory, getLastReleaseTag, git } from "./releaseGit";
|
|
21
19
|
|
|
22
20
|
const CHANGESET_DIR = ".changeset";
|
|
23
21
|
const CHANGELOG_FILE = "CHANGELOG.md";
|
|
@@ -29,12 +27,7 @@ const CHANGELOG_FILE = "CHANGELOG.md";
|
|
|
29
27
|
*/
|
|
30
28
|
const GITHUB_TAGGED_RELEASE_WORKFLOW = "catladder-release.yml";
|
|
31
29
|
|
|
32
|
-
const
|
|
33
|
-
const { stdout } = await execFile("git", args);
|
|
34
|
-
return stdout.trim();
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const readPendingChangesets = async () => {
|
|
30
|
+
export const readPendingChangesets = async () => {
|
|
38
31
|
const entries = await readdir(CHANGESET_DIR).catch(() => []);
|
|
39
32
|
const files = entries.filter(
|
|
40
33
|
(file) => file.endsWith(".md") && file.toLowerCase() !== "readme.md",
|
|
@@ -49,40 +42,6 @@ const readPendingChangesets = async () => {
|
|
|
49
42
|
);
|
|
50
43
|
};
|
|
51
44
|
|
|
52
|
-
/**
|
|
53
|
-
* CI checkouts are usually shallow and tagless (gitlab clones with
|
|
54
|
-
* depth 1; github's checkout needs fetch-depth: 0) — the local history
|
|
55
|
-
* then cannot answer "nearest v* tag" and the job would rederive the
|
|
56
|
-
* FIRST release version. Deepen and fetch tags before deriving.
|
|
57
|
-
*/
|
|
58
|
-
const ensureReleaseHistory = async () => {
|
|
59
|
-
const shallow = await git("rev-parse", "--is-shallow-repository").catch(
|
|
60
|
-
() => "false",
|
|
61
|
-
);
|
|
62
|
-
try {
|
|
63
|
-
if (shallow === "true") {
|
|
64
|
-
await git("fetch", "--quiet", "--unshallow", "--tags", "origin");
|
|
65
|
-
} else {
|
|
66
|
-
await git("fetch", "--quiet", "--tags", "origin");
|
|
67
|
-
}
|
|
68
|
-
} catch (e) {
|
|
69
|
-
console.warn(
|
|
70
|
-
`could not fetch tags from origin (${e}) — version derivation may be wrong on a shallow clone`,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const getLastReleaseTag = async (): Promise<string | null> => {
|
|
76
|
-
try {
|
|
77
|
-
// the nearest v* tag in the history of HEAD — on hotfix branches
|
|
78
|
-
// this is deliberately the branch's own release line, not the
|
|
79
|
-
// globally highest version
|
|
80
|
-
return await git("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*");
|
|
81
|
-
} catch {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
|
|
86
45
|
const ensureGitIdentity = async () => {
|
|
87
46
|
const email = await git("config", "user.email").catch(() => "");
|
|
88
47
|
if (email !== "") return;
|
|
@@ -164,17 +123,38 @@ export const dispatchTaggedReleaseWorkflow = async (tag: string) => {
|
|
|
164
123
|
console.log(`dispatched ${GITHUB_TAGGED_RELEASE_WORKFLOW} for ${tag}`);
|
|
165
124
|
};
|
|
166
125
|
|
|
126
|
+
/**
|
|
127
|
+
* the escape hatch of the force-release job: releasing without pending
|
|
128
|
+
* changesets (e.g. a hotfix merged without one) must not require a new
|
|
129
|
+
* MR, so a forced empty release becomes a patch bump with a generic
|
|
130
|
+
* changelog entry instead of a no-op
|
|
131
|
+
*/
|
|
132
|
+
const FORCED_RELEASE_CHANGESET: Changeset = {
|
|
133
|
+
fileName: "",
|
|
134
|
+
bump: "patch",
|
|
135
|
+
summary: "Forced release without pending changesets.",
|
|
136
|
+
};
|
|
137
|
+
|
|
167
138
|
export const changesetsReleaseJob = async () => {
|
|
168
|
-
const
|
|
169
|
-
|
|
139
|
+
const pending = await readPendingChangesets();
|
|
140
|
+
const forcedEmpty =
|
|
141
|
+
pending.length === 0 && process.env.CATLADDER_FORCE_RELEASE === "true";
|
|
142
|
+
if (pending.length === 0 && !forcedEmpty) {
|
|
170
143
|
console.log(
|
|
171
144
|
`no changesets found in ${CHANGESET_DIR}/ — nothing to release`,
|
|
172
145
|
);
|
|
173
146
|
console.log(
|
|
174
147
|
"add one with `yarn changeset` (or write .changeset/<name>.md by hand) and merge it to release",
|
|
175
148
|
);
|
|
149
|
+
console.log(
|
|
150
|
+
"to release anyway (patch bump), run the force release job instead",
|
|
151
|
+
);
|
|
176
152
|
return;
|
|
177
153
|
}
|
|
154
|
+
const changesets = forcedEmpty ? [FORCED_RELEASE_CHANGESET] : pending;
|
|
155
|
+
if (forcedEmpty) {
|
|
156
|
+
console.log("no changesets pending — forced release, bumping a patch");
|
|
157
|
+
}
|
|
178
158
|
|
|
179
159
|
await ensureReleaseHistory();
|
|
180
160
|
const lastTag = await getLastReleaseTag();
|
|
@@ -197,11 +177,19 @@ export const changesetsReleaseJob = async () => {
|
|
|
197
177
|
prependToChangelog(existingChangelog, version, date, entries),
|
|
198
178
|
);
|
|
199
179
|
await Promise.all(
|
|
200
|
-
changesets
|
|
180
|
+
// pending, not changesets: the synthetic forced-release changeset
|
|
181
|
+
// has no file to delete
|
|
182
|
+
pending.map((changeset) => rm(join(CHANGESET_DIR, changeset.fileName))),
|
|
201
183
|
);
|
|
202
184
|
|
|
203
185
|
await ensureGitIdentity();
|
|
204
|
-
|
|
186
|
+
// a forced empty release consumes no changeset files — and .changeset/
|
|
187
|
+
// may not even exist yet, which `git add` would reject
|
|
188
|
+
if (forcedEmpty) {
|
|
189
|
+
await git("add", CHANGELOG_FILE);
|
|
190
|
+
} else {
|
|
191
|
+
await git("add", CHANGELOG_FILE, CHANGESET_DIR);
|
|
192
|
+
}
|
|
205
193
|
await git("commit", "-m", `chore(release): ${version}\n\n${entries}`);
|
|
206
194
|
await git("tag", tag);
|
|
207
195
|
await pushCommitAndTag(tag);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git helpers shared by the release-related catci jobs (changesets
|
|
3
|
+
* release, changeset check).
|
|
4
|
+
*/
|
|
5
|
+
import { execFile as execFileCb } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
9
|
+
|
|
10
|
+
export const git = async (...args: string[]): Promise<string> => {
|
|
11
|
+
const { stdout } = await execFile("git", args);
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* CI checkouts are usually shallow and tagless (gitlab clones with
|
|
17
|
+
* depth 1; github's checkout needs fetch-depth: 0) — the local history
|
|
18
|
+
* then cannot answer "nearest v* tag" and the job would rederive the
|
|
19
|
+
* FIRST release version. Deepen and fetch tags before deriving.
|
|
20
|
+
*/
|
|
21
|
+
export const ensureReleaseHistory = async () => {
|
|
22
|
+
const shallow = await git("rev-parse", "--is-shallow-repository").catch(
|
|
23
|
+
() => "false",
|
|
24
|
+
);
|
|
25
|
+
try {
|
|
26
|
+
if (shallow === "true") {
|
|
27
|
+
await git("fetch", "--quiet", "--unshallow", "--tags", "origin");
|
|
28
|
+
} else {
|
|
29
|
+
await git("fetch", "--quiet", "--tags", "origin");
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.warn(
|
|
33
|
+
`could not fetch tags from origin (${e}) — version derivation may be wrong on a shallow clone`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const getLastReleaseTag = async (): Promise<string | null> => {
|
|
39
|
+
try {
|
|
40
|
+
// the nearest v* tag in the history of HEAD — on hotfix branches
|
|
41
|
+
// this is deliberately the branch's own release line, not the
|
|
42
|
+
// globally highest version
|
|
43
|
+
return await git("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*");
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
};
|