@catladder/cli 0.0.0-next-883523ef → 0.0.0-next-d71e41ba

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 (41) hide show
  1. package/dist/apps/cli/src/catci.js +12 -0
  2. package/dist/apps/cli/src/catci.js.map +1 -1
  3. package/dist/apps/cli/src/release/changesetCheck.d.ts +35 -0
  4. package/dist/apps/cli/src/release/changesetCheck.js +31 -0
  5. package/dist/apps/cli/src/release/changesetCheck.js.map +1 -0
  6. package/dist/apps/cli/src/release/changesetCheckJob.d.ts +2 -0
  7. package/dist/apps/cli/src/release/changesetCheckJob.js +169 -0
  8. package/dist/apps/cli/src/release/changesetCheckJob.js.map +1 -0
  9. package/dist/apps/cli/src/release/changesetsReleaseJob.d.ts +2 -0
  10. package/dist/apps/cli/src/release/changesetsReleaseJob.js +43 -52
  11. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  12. package/dist/apps/cli/src/release/releaseGit.d.ts +9 -0
  13. package/dist/apps/cli/src/release/releaseGit.js +49 -0
  14. package/dist/apps/cli/src/release/releaseGit.js.map +1 -0
  15. package/dist/bundles/catci/index.js +4 -4
  16. package/dist/bundles/catenv/index.js +109 -4
  17. package/dist/bundles/cli/index.js +109 -4
  18. package/dist/bundles/runner-images/changesets/scripts/changesetCheck +23 -0
  19. package/dist/bundles/skills/catladder-releases/SKILL.md +31 -2
  20. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +3 -0
  21. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  22. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.d.ts +7 -0
  23. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js +75 -4
  24. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js.map +1 -1
  25. package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.d.ts +26 -4
  26. package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.js +29 -0
  27. package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.js.map +1 -1
  28. package/dist/packages/pipeline/src/release/index.d.ts +12 -0
  29. package/dist/packages/pipeline/src/release/index.js +2 -0
  30. package/dist/packages/pipeline/src/release/index.js.map +1 -1
  31. package/dist/packages/pipeline/src/types/github-types.d.ts +1 -0
  32. package/dist/runner-images/changesets/scripts/changesetCheck +23 -0
  33. package/dist/skills/catladder-releases/SKILL.md +31 -2
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +1 -1
  36. package/src/catci.ts +14 -0
  37. package/src/release/__tests__/changesetCheck.test.ts +69 -0
  38. package/src/release/changesetCheck.ts +82 -0
  39. package/src/release/changesetCheckJob.ts +199 -0
  40. package/src/release/changesetsReleaseJob.ts +36 -48
  41. package/src/release/releaseGit.ts +47 -0
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-next-883523ef",
56
+ "version": "0.0.0-next-d71e41ba",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
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 { promisify } from "util";
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 git = async (...args: string[]): Promise<string> => {
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 changesets = await readPendingChangesets();
169
- if (changesets.length === 0) {
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.map((changeset) => rm(join(CHANGESET_DIR, changeset.fileName))),
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
- await git("add", CHANGELOG_FILE, CHANGESET_DIR);
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
+ };