@catladder/cli 5.0.0 → 5.0.1

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/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "5.0.0",
56
+ "version": "5.0.1",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -0,0 +1,88 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createReleaseEntry } from "../releaseEntry";
3
+
4
+ const gitlabEnv = {
5
+ GITHUB_ACTIONS: undefined,
6
+ GL_TOKEN: "glpat-token",
7
+ CI_API_V4_URL: "https://git.example.com/api/v4",
8
+ CI_PROJECT_ID: "42",
9
+ };
10
+
11
+ const githubEnv = {
12
+ GITHUB_ACTIONS: "true",
13
+ GITHUB_TOKEN: "gh-token",
14
+ GITHUB_REPOSITORY: "panter/catladder",
15
+ };
16
+
17
+ const stubEnv = (env: Record<string, string | undefined>) => {
18
+ Object.entries(env).forEach(([key, value]) => vi.stubEnv(key, value as any));
19
+ };
20
+
21
+ const stubFetch = (response: Partial<Response> = {}) => {
22
+ const fetchMock = vi.fn().mockResolvedValue({
23
+ ok: true,
24
+ status: 201,
25
+ text: async () => "",
26
+ ...response,
27
+ });
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ return fetchMock;
30
+ };
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllEnvs();
34
+ vi.unstubAllGlobals();
35
+ });
36
+
37
+ describe("createReleaseEntry", () => {
38
+ it("creates the gitlab release for the pushed tag", async () => {
39
+ stubEnv(gitlabEnv);
40
+ const fetchMock = stubFetch();
41
+
42
+ await createReleaseEntry("v1.2.0", "- Add the export endpoint");
43
+
44
+ expect(fetchMock).toHaveBeenCalledTimes(1);
45
+ const [url, init] = fetchMock.mock.calls[0];
46
+ expect(url).toBe("https://git.example.com/api/v4/projects/42/releases");
47
+ expect(init.method).toBe("POST");
48
+ expect(init.headers["PRIVATE-TOKEN"]).toBe("glpat-token");
49
+ expect(JSON.parse(init.body)).toEqual({
50
+ tag_name: "v1.2.0",
51
+ description: "- Add the export endpoint",
52
+ });
53
+ });
54
+
55
+ it("creates the github release for the pushed tag", async () => {
56
+ stubEnv(githubEnv);
57
+ const fetchMock = stubFetch();
58
+
59
+ await createReleaseEntry("v1.2.0", "- Add the export endpoint");
60
+
61
+ const [url, init] = fetchMock.mock.calls[0];
62
+ expect(url).toBe("https://api.github.com/repos/panter/catladder/releases");
63
+ expect(init.headers.authorization).toBe("Bearer gh-token");
64
+ expect(JSON.parse(init.body)).toEqual({
65
+ tag_name: "v1.2.0",
66
+ name: "v1.2.0",
67
+ body: "- Add the export endpoint",
68
+ });
69
+ });
70
+
71
+ it("does not throw when the api call fails — the tag is already pushed", async () => {
72
+ stubEnv(gitlabEnv);
73
+ stubFetch({ ok: false, status: 403, text: async () => "forbidden" });
74
+
75
+ await expect(
76
+ createReleaseEntry("v1.2.0", "- notes"),
77
+ ).resolves.toBeUndefined();
78
+ });
79
+
80
+ it("skips without api context instead of failing the release", async () => {
81
+ stubEnv({ ...gitlabEnv, GL_TOKEN: undefined });
82
+ const fetchMock = stubFetch();
83
+
84
+ await createReleaseEntry("v1.2.0", "- notes");
85
+
86
+ expect(fetchMock).not.toHaveBeenCalled();
87
+ });
88
+ });
@@ -23,6 +23,7 @@ import {
23
23
  git,
24
24
  gitWithEnv,
25
25
  } from "./releaseGit";
26
+ import { createReleaseEntry } from "./releaseEntry";
26
27
  import { appendStepSummary } from "./stepSummary";
27
28
 
28
29
  const CHANGESET_DIR = ".changeset";
@@ -276,6 +277,8 @@ export const changesetsReleaseJob = async () => {
276
277
  await pushCommitAndTag(tag);
277
278
  console.log(`released ${tag}`);
278
279
  appendStepSummary(`🚀 **Released ${tag}**\n\n${entries}`);
280
+ // the tag alone creates no entry on the releases page of the git host
281
+ await createReleaseEntry(tag, entries);
279
282
 
280
283
  if (process.env.GITHUB_ACTIONS === "true") {
281
284
  await dispatchTaggedReleaseWorkflow(tag);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * the release entry on the git host — gitlab's `/-/releases`, github's
3
+ * `/releases`. Pushing a tag does NOT create one: on the
4
+ * semantic-release path `@semantic-release/gitlab` used to do it, the
5
+ * changesets path creates it here (on both backends).
6
+ *
7
+ * Never fatal: when this runs, the release commit and tag are already
8
+ * pushed and the taggedRelease pipeline is on its way — a failing api
9
+ * call must not turn a done release into a red job (and a rerun would
10
+ * fail on the existing tag).
11
+ */
12
+
13
+ const post = async (
14
+ url: string,
15
+ headers: Record<string, string>,
16
+ body: unknown,
17
+ ) => {
18
+ const response = await fetch(url, {
19
+ method: "POST",
20
+ headers: { "content-type": "application/json", ...headers },
21
+ body: JSON.stringify(body),
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(
25
+ `POST ${url} failed: ${response.status} ${await response.text()}`,
26
+ );
27
+ }
28
+ };
29
+
30
+ /**
31
+ * GL_TOKEN is the project access token the release push already used,
32
+ * so its api scope is available here too
33
+ */
34
+ const createGitlabReleaseEntry = async (tag: string, notes: string) => {
35
+ const token = process.env.GL_TOKEN;
36
+ const apiUrl = process.env.CI_API_V4_URL;
37
+ const projectId = process.env.CI_PROJECT_ID;
38
+ if (!token || !apiUrl || !projectId) {
39
+ console.warn(
40
+ "no GL_TOKEN / gitlab api context — skipping the release entry (the tag is pushed)",
41
+ );
42
+ return;
43
+ }
44
+ await post(
45
+ `${apiUrl}/projects/${projectId}/releases`,
46
+ { "PRIVATE-TOKEN": token },
47
+ // name defaults to the tag on gitlab
48
+ { tag_name: tag, description: notes },
49
+ );
50
+ console.log(`created the gitlab release entry for ${tag}`);
51
+ };
52
+
53
+ /** the workflow token, granted `contents: write` by the release job */
54
+ const createGithubReleaseEntry = async (tag: string, notes: string) => {
55
+ const token = process.env.GITHUB_TOKEN;
56
+ const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
57
+ const repository = process.env.GITHUB_REPOSITORY;
58
+ if (!token || !repository) {
59
+ console.warn(
60
+ "no GITHUB_TOKEN / repository context — skipping the release entry (the tag is pushed)",
61
+ );
62
+ return;
63
+ }
64
+ await post(
65
+ `${apiUrl}/repos/${repository}/releases`,
66
+ { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
67
+ { tag_name: tag, name: tag, body: notes },
68
+ );
69
+ console.log(`created the github release entry for ${tag}`);
70
+ };
71
+
72
+ export const createReleaseEntry = async (tag: string, notes: string) => {
73
+ try {
74
+ if (process.env.GITHUB_ACTIONS === "true") {
75
+ await createGithubReleaseEntry(tag, notes);
76
+ } else {
77
+ await createGitlabReleaseEntry(tag, notes);
78
+ }
79
+ } catch (error) {
80
+ console.warn(
81
+ `⚠️ could not create the release entry for ${tag}: ${error}\n` +
82
+ "the release itself is done (commit, tag and changelog are pushed) — " +
83
+ "create the entry by hand if you need it",
84
+ );
85
+ }
86
+ };