@catladder/cli 5.1.1 → 5.1.3

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 (43) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.d.ts +30 -0
  2. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.js +89 -0
  3. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.js.map +1 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +2 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
  6. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.d.ts +4 -1
  7. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +4 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js.map +1 -1
  9. package/dist/apps/cli/src/catci.js +13 -0
  10. package/dist/apps/cli/src/catci.js.map +1 -1
  11. package/dist/apps/cli/src/release/changesetsReleaseJob.js +20 -42
  12. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  13. package/dist/apps/cli/src/release/githubDeployKey.d.ts +36 -0
  14. package/dist/apps/cli/src/release/githubDeployKey.js +126 -0
  15. package/dist/apps/cli/src/release/githubDeployKey.js.map +1 -0
  16. package/dist/bundles/catci/index.js +5 -5
  17. package/dist/bundles/catenv/index.js +31 -7
  18. package/dist/bundles/cli/index.js +133 -8
  19. package/dist/bundles/runner-images/semantic-release/Dockerfile +3 -1
  20. package/dist/bundles/runner-images/semantic-release/scripts/semanticRelease +37 -4
  21. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +15 -6
  22. package/dist/bundles/skills/catladder-pipelines/SKILL.md +3 -2
  23. package/dist/bundles/skills/catladder-releases/SKILL.md +14 -0
  24. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +12 -3
  25. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  26. package/dist/packages/pipeline/src/catci/shippedCatci.d.ts +8 -0
  27. package/dist/packages/pipeline/src/catci/shippedCatci.js +19 -4
  28. package/dist/packages/pipeline/src/catci/shippedCatci.js.map +1 -1
  29. package/dist/runner-images/semantic-release/Dockerfile +3 -1
  30. package/dist/runner-images/semantic-release/scripts/semanticRelease +37 -4
  31. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +15 -6
  32. package/dist/skills/catladder-pipelines/SKILL.md +3 -2
  33. package/dist/skills/catladder-releases/SKILL.md +14 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +1 -1
  36. package/src/apps/cli/commands/project/__tests__/checkGithubReleaseWorkflow.test.ts +89 -0
  37. package/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.ts +125 -0
  38. package/src/apps/cli/commands/project/doctor/index.ts +2 -0
  39. package/src/apps/cli/commands/project/githubMergeGating.ts +4 -1
  40. package/src/catci.ts +15 -0
  41. package/src/release/__tests__/githubDeployKey.test.ts +148 -0
  42. package/src/release/changesetsReleaseJob.ts +28 -57
  43. package/src/release/githubDeployKey.ts +141 -0
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "5.1.1",
56
+ "version": "5.1.3",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findReleaseJobImageDrift } from "../doctor/checkGithubReleaseWorkflow";
3
+
4
+ const CURRENT = "ghcr.io/fiulag/nautilus/catladder-semantic-release:abc123";
5
+ const OLD = "ghcr.io/fiulag/nautilus/catladder-semantic-release:0ld";
6
+
7
+ const workflow = (image: string) => ({
8
+ name: "🚀 catladder create release",
9
+ on: { workflow_dispatch: {} },
10
+ jobs: {
11
+ "create-release": {
12
+ name: "create release",
13
+ "runs-on": "ubuntu-latest",
14
+ container: { image },
15
+ steps: [{ run: "semanticRelease" }],
16
+ },
17
+ },
18
+ });
19
+
20
+ const committedYaml = (image: string) => `
21
+ name: 🚀 catladder create release
22
+ jobs:
23
+ create-release:
24
+ name: create release
25
+ container:
26
+ image: ${image}
27
+ `;
28
+
29
+ const generated = {
30
+ "catladder-create-release.yml": workflow(CURRENT),
31
+ "catladder-review.yml": {
32
+ name: "🛠️ catladder review",
33
+ on: {},
34
+ jobs: { lint: { name: "lint", "runs-on": "ubuntu-latest", steps: [] } },
35
+ },
36
+ } as any;
37
+
38
+ describe("findReleaseJobImageDrift", () => {
39
+ it("is quiet when the committed release job runs the generated image", () => {
40
+ expect(
41
+ findReleaseJobImageDrift(generated, {
42
+ "catladder-create-release.yml": committedYaml(CURRENT),
43
+ }),
44
+ ).toEqual([]);
45
+ });
46
+
47
+ it("reports a committed workflow generated by an older catladder (older image tag)", () => {
48
+ expect(
49
+ findReleaseJobImageDrift(generated, {
50
+ "catladder-create-release.yml": committedYaml(OLD),
51
+ }),
52
+ ).toEqual([
53
+ {
54
+ file: "catladder-create-release.yml",
55
+ expected: CURRENT,
56
+ committed: OLD,
57
+ },
58
+ ]);
59
+ });
60
+
61
+ it("reports a missing or unparsable committed workflow", () => {
62
+ expect(
63
+ findReleaseJobImageDrift(generated, {
64
+ "catladder-create-release.yml": null,
65
+ }),
66
+ ).toEqual([
67
+ {
68
+ file: "catladder-create-release.yml",
69
+ expected: CURRENT,
70
+ committed: null,
71
+ },
72
+ ]);
73
+ expect(
74
+ findReleaseJobImageDrift(generated, {
75
+ "catladder-create-release.yml": "jobs: [not: a: workflow",
76
+ }),
77
+ ).toEqual([
78
+ {
79
+ file: "catladder-create-release.yml",
80
+ expected: CURRENT,
81
+ committed: null,
82
+ },
83
+ ]);
84
+ });
85
+
86
+ it("only looks at workflows carrying a release job", () => {
87
+ expect(findReleaseJobImageDrift(generated, {})).toHaveLength(1);
88
+ });
89
+ });
@@ -0,0 +1,125 @@
1
+ import { readFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import type { Config } from "@catladder/pipeline";
4
+ import {
5
+ getEnabledPipelineTypes,
6
+ GithubBackend,
7
+ JobImagesPlan,
8
+ resolveGithubRegistryImage,
9
+ } from "@catladder/pipeline";
10
+ import { parse } from "yaml";
11
+ import type { DoctorReport } from "./DoctorReport";
12
+
13
+ const WORKFLOWS_FOLDER = ".github/workflows";
14
+
15
+ /** the job name of the release job, see githubReleaseJobs.ts */
16
+ const RELEASE_JOB_NAME = "create release";
17
+
18
+ type Workflows = Awaited<ReturnType<GithubBackend["createWorkflows"]>>;
19
+
20
+ export type ReleaseJobImageDrift = {
21
+ file: string;
22
+ expected: string;
23
+ /** null when the committed workflow has no release job (or no file) */
24
+ committed: string | null;
25
+ };
26
+
27
+ const releaseJobImage = (workflow: {
28
+ jobs?: Record<string, { name?: string; container?: unknown }>;
29
+ }): string | null => {
30
+ const job = Object.values(workflow.jobs ?? {}).find(
31
+ (job) => job?.name === RELEASE_JOB_NAME,
32
+ );
33
+ const container = job?.container;
34
+ if (typeof container === "string") return container;
35
+ if (container && typeof container === "object" && "image" in container) {
36
+ return `${(container as { image: unknown }).image}`;
37
+ }
38
+ return null;
39
+ };
40
+
41
+ /**
42
+ * compares the release job image of every generated workflow carrying
43
+ * one against the committed workflow of the same name. The job images
44
+ * are tagged by a content hash of their definition, so a differing tag
45
+ * means the committed workflow runs an older release image.
46
+ */
47
+ export const findReleaseJobImageDrift = (
48
+ generated: Workflows,
49
+ committed: Record<string, string | null>,
50
+ ): ReleaseJobImageDrift[] =>
51
+ Object.entries(generated).flatMap(([file, workflow]) => {
52
+ const expected = releaseJobImage(workflow);
53
+ if (!expected) return [];
54
+ const content = committed[file];
55
+ let committedImage: string | null = null;
56
+ if (content) {
57
+ try {
58
+ committedImage = releaseJobImage(parse(content) ?? {});
59
+ } catch {
60
+ committedImage = null;
61
+ }
62
+ }
63
+ return committedImage === expected
64
+ ? []
65
+ : [{ file, expected, committed: committedImage }];
66
+ });
67
+
68
+ /**
69
+ * the release job image in the committed workflows must be the one this
70
+ * catladder version generates. The image tag encodes the image
71
+ * definition, so an older tag means an older release script — which is
72
+ * more than cosmetics: the semantic-release image before this check
73
+ * pushed with the workflow token and ignored the release deploy key,
74
+ * so every release on a repository with merge gating failed on GH013
75
+ * while `project setup` and the deploy-key checks reported all green.
76
+ *
77
+ * Needs no `gh` login — only the config and the committed files.
78
+ */
79
+ export const checkGithubReleaseWorkflow = async (
80
+ report: DoctorReport,
81
+ config: Config,
82
+ ) => {
83
+ if (!getEnabledPipelineTypes(config).includes("github")) return;
84
+ report.section("github release workflow");
85
+
86
+ let generated: Workflows;
87
+ try {
88
+ const images = new JobImagesPlan(
89
+ "github",
90
+ config.images,
91
+ await resolveGithubRegistryImage(config),
92
+ );
93
+ generated = await new GithubBackend().createWorkflows(config, images);
94
+ } catch (e) {
95
+ report.warn(`could not generate the github workflows (${e.message})`);
96
+ return;
97
+ }
98
+
99
+ const files = Object.keys(generated).filter((file) =>
100
+ releaseJobImage(generated[file]),
101
+ );
102
+ const committed: Record<string, string | null> = {};
103
+ for (const file of files) {
104
+ committed[file] = await readFile(
105
+ join(WORKFLOWS_FOLDER, file),
106
+ "utf8",
107
+ ).catch(() => null);
108
+ }
109
+
110
+ const drift = findReleaseJobImageDrift(generated, committed);
111
+ if (drift.length === 0) {
112
+ report.ok(
113
+ `release job image up to date in ${files.length} generated workflow(s)`,
114
+ );
115
+ return;
116
+ }
117
+ for (const { file, expected, committed } of drift) {
118
+ report.fail(
119
+ committed
120
+ ? `${WORKFLOWS_FOLDER}/${file}: the release job runs ${committed}, this catladder version generates ${expected} — the committed workflow was generated by an older catladder (its release script may push with the workflow token, ignoring the release deploy key)`
121
+ : `${WORKFLOWS_FOLDER}/${file}: missing or without a '${RELEASE_JOB_NAME}' job (expected image ${expected})`,
122
+ "run: catenv (and commit the regenerated workflows)",
123
+ );
124
+ }
125
+ };
@@ -8,6 +8,7 @@ import type { IO } from "../../../../../core/types";
8
8
  import { checkCloudRun } from "./checkCloudRun";
9
9
  import { checkGithub } from "./checkGithub";
10
10
  import { checkGithubImageCasing } from "./checkGithubImageCasing";
11
+ import { checkGithubReleaseWorkflow } from "./checkGithubReleaseWorkflow";
11
12
  import { checkGitlab } from "./checkGitlab";
12
13
  import { checkStore } from "./checkStore";
13
14
  import { DoctorReport } from "./DoctorReport";
@@ -55,6 +56,7 @@ export const doctorProject = async (
55
56
  }
56
57
  await checkCloudRun(report, contexts);
57
58
  await checkGithubImageCasing(report, config);
59
+ await checkGithubReleaseWorkflow(report, config);
58
60
  await checkGithub(report, config);
59
61
 
60
62
  report.summarize();
@@ -32,7 +32,10 @@ export const RELEASE_DEPLOY_KEY_TITLE = "catladder release";
32
32
 
33
33
  /**
34
34
  * actions secret holding the deploy key's private half; the generated
35
- * release jobs pass it to catci, which pushes over ssh when it is set
35
+ * release jobs pass it into the job env and both release methods push
36
+ * over ssh with it when it is set — changesets from catci itself,
37
+ * semantic-release via the ssh repository url catci prepares for it
38
+ * (see src/release/githubDeployKey.ts)
36
39
  */
37
40
  export const RELEASE_DEPLOY_KEY_SECRET = "CATLADDER_RELEASE_KEY";
38
41
 
package/src/catci.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  dispatchTaggedReleaseWorkflow,
25
25
  } from "./release/changesetsReleaseJob";
26
26
  import { changesetCheckJob } from "./release/changesetCheckJob";
27
+ import { githubDeployKeyRemoteJob } from "./release/githubDeployKey";
27
28
  import {
28
29
  githubQueueCheckJob,
29
30
  githubQueuedGuardJob,
@@ -58,6 +59,13 @@ usage:
58
59
  github only: dispatches the generated taggedRelease workflow for
59
60
  the tag (tags pushed with the job token don't trigger it)
60
61
 
62
+ catci release github-deploy-key-remote
63
+ github only, semantic-release path: configures the checkout to
64
+ push over ssh with the release deploy key (CATLADDER_RELEASE_KEY,
65
+ the only actor that bypasses the merge-gating ruleset), verifies
66
+ the key reaches the repository and prints the ssh url for
67
+ semantic-release's --repository-url
68
+
61
69
  catci release github-queue-check
62
70
  github only, first step of the manual create-release task:
63
71
  releases right away when the main workflow run for HEAD is green
@@ -175,6 +183,13 @@ const main = async () => {
175
183
  ) {
176
184
  return dispatchTaggedReleaseWorkflow(args[0]);
177
185
  }
186
+ if (
187
+ group === "release" &&
188
+ command === "github-deploy-key-remote" &&
189
+ args.length === 0
190
+ ) {
191
+ return githubDeployKeyRemoteJob();
192
+ }
178
193
  if (
179
194
  group === "release" &&
180
195
  command === "github-queue-check" &&
@@ -0,0 +1,148 @@
1
+ import { readFile, stat } from "fs/promises";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ configureDeployKeyRemote,
5
+ deployKeyRemoteUrl,
6
+ deployKeySshCommand,
7
+ githubDeployKeyRemoteJob,
8
+ pushWithDeployKey,
9
+ RELEASE_DEPLOY_KEY_ENV,
10
+ writeDeployKeyFile,
11
+ } from "../githubDeployKey";
12
+ import { git, gitWithEnv } from "../releaseGit";
13
+
14
+ vi.mock("../releaseGit", () => ({
15
+ git: vi.fn(async () => ""),
16
+ gitWithEnv: vi.fn(async () => ""),
17
+ }));
18
+
19
+ const KEY =
20
+ "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----";
21
+
22
+ describe("github release deploy key", () => {
23
+ const env = { ...process.env };
24
+ beforeEach(() => {
25
+ process.env.GITHUB_REPOSITORY = "FiulAG/nautilus";
26
+ delete process.env.GITHUB_SERVER_URL;
27
+ delete process.env[RELEASE_DEPLOY_KEY_ENV];
28
+ vi.mocked(git).mockClear();
29
+ vi.mocked(gitWithEnv).mockClear();
30
+ vi.mocked(git).mockResolvedValue("");
31
+ });
32
+ afterEach(() => {
33
+ process.env = { ...env };
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ describe("deployKeyRemoteUrl", () => {
38
+ it("is the ssh url of the repository on github.com", () => {
39
+ expect(deployKeyRemoteUrl()).toBe("git@github.com:FiulAG/nautilus.git");
40
+ });
41
+
42
+ it("follows GITHUB_SERVER_URL on an enterprise host", () => {
43
+ process.env.GITHUB_SERVER_URL = "https://github.example.com";
44
+ expect(deployKeyRemoteUrl()).toBe(
45
+ "git@github.example.com:FiulAG/nautilus.git",
46
+ );
47
+ });
48
+
49
+ it("fails without the repository", () => {
50
+ delete process.env.GITHUB_REPOSITORY;
51
+ expect(() => deployKeyRemoteUrl()).toThrow(
52
+ "GITHUB_REPOSITORY is not set",
53
+ );
54
+ });
55
+ });
56
+
57
+ describe("writeDeployKeyFile", () => {
58
+ it("writes the key with a trailing newline, readable by the owner only", async () => {
59
+ const { keyFile } = await writeDeployKeyFile(KEY);
60
+ expect(await readFile(keyFile, "utf8")).toBe(`${KEY}\n`);
61
+ expect((await stat(keyFile)).mode & 0o777).toBe(0o600);
62
+ });
63
+
64
+ it("does not add a second newline", async () => {
65
+ const { keyFile } = await writeDeployKeyFile(`${KEY}\n`);
66
+ expect(await readFile(keyFile, "utf8")).toBe(`${KEY}\n`);
67
+ });
68
+ });
69
+
70
+ it("the ssh command uses exactly the deploy key", () => {
71
+ expect(deployKeySshCommand("/tmp/k/id_ed25519")).toBe(
72
+ "ssh -i /tmp/k/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new",
73
+ );
74
+ });
75
+
76
+ describe("pushWithDeployKey (changesets path)", () => {
77
+ it("pushes the refs atomically to the ssh url with the key", async () => {
78
+ await pushWithDeployKey(KEY, ["HEAD:refs/heads/main", "v1.2.3"]);
79
+ expect(gitWithEnv).toHaveBeenCalledTimes(1);
80
+ const [env, ...args] = vi.mocked(gitWithEnv).mock.calls[0];
81
+ expect(env.GIT_SSH_COMMAND).toMatch(
82
+ /^ssh -i \S+id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new$/,
83
+ );
84
+ expect(args).toEqual([
85
+ "push",
86
+ "--atomic",
87
+ "git@github.com:FiulAG/nautilus.git",
88
+ "HEAD:refs/heads/main",
89
+ "v1.2.3",
90
+ ]);
91
+ });
92
+ });
93
+
94
+ describe("configureDeployKeyRemote (semantic-release path)", () => {
95
+ it("points git's ssh at the key, verifies the key reaches the repo and returns the ssh url", async () => {
96
+ const remote = await configureDeployKeyRemote(KEY);
97
+ expect(remote).toBe("git@github.com:FiulAG/nautilus.git");
98
+ const calls = vi.mocked(git).mock.calls;
99
+ expect(calls[0].slice(0, 2)).toEqual(["config", "core.sshCommand"]);
100
+ expect(calls[0][2]).toMatch(
101
+ /^ssh -i \S+id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new$/,
102
+ );
103
+ expect(calls[1]).toEqual([
104
+ "ls-remote",
105
+ "--exit-code",
106
+ "git@github.com:FiulAG/nautilus.git",
107
+ "HEAD",
108
+ ]);
109
+ });
110
+
111
+ it("fails with the remediation when the key does not reach the repository", async () => {
112
+ vi.mocked(git).mockImplementation(async (...args) => {
113
+ if (args[0] === "ls-remote") {
114
+ throw new Error("git@github.com: Permission denied (publickey).");
115
+ }
116
+ return "";
117
+ });
118
+ await expect(configureDeployKeyRemote(KEY)).rejects.toThrow(
119
+ /cannot reach git@github\.com:FiulAG\/nautilus\.git: .*Permission denied[\s\S]*CATLADDER_RELEASE_KEY secret does not match the 'catladder release' deploy key[\s\S]*catladder project setup/,
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("githubDeployKeyRemoteJob (catci release github-deploy-key-remote)", () => {
125
+ it("prints only the ssh url on stdout, the log on stderr", async () => {
126
+ process.env[RELEASE_DEPLOY_KEY_ENV] = KEY;
127
+ const stdout = vi
128
+ .spyOn(process.stdout, "write")
129
+ .mockImplementation(() => true);
130
+ const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
131
+ await githubDeployKeyRemoteJob();
132
+ expect(stdout).toHaveBeenCalledTimes(1);
133
+ expect(stdout).toHaveBeenCalledWith(
134
+ "git@github.com:FiulAG/nautilus.git\n",
135
+ );
136
+ expect(stderr).toHaveBeenCalledWith(
137
+ expect.stringContaining("over ssh with the release deploy key"),
138
+ );
139
+ });
140
+
141
+ it("refuses to run without the secret", async () => {
142
+ await expect(githubDeployKeyRemoteJob()).rejects.toThrow(
143
+ `${RELEASE_DEPLOY_KEY_ENV} is not set`,
144
+ );
145
+ expect(git).not.toHaveBeenCalled();
146
+ });
147
+ });
148
+ });
@@ -6,8 +6,7 @@
6
6
  * pushes — the pushed tag triggers the taggedRelease pipeline.
7
7
  */
8
8
  import { existsSync } from "fs";
9
- import { mkdtemp, readFile, readdir, rm, writeFile } from "fs/promises";
10
- import { tmpdir } from "os";
9
+ import { readFile, readdir, rm, writeFile } from "fs/promises";
11
10
  import { join } from "path";
12
11
  import type { Changeset } from "./changesets";
13
12
  import {
@@ -18,11 +17,11 @@ import {
18
17
  renderChangelogEntries,
19
18
  } from "./changesets";
20
19
  import {
21
- ensureReleaseHistory,
22
- getLastReleaseTag,
23
- git,
24
- gitWithEnv,
25
- } from "./releaseGit";
20
+ getReleaseDeployKey,
21
+ pushWithDeployKey,
22
+ RELEASE_DEPLOY_KEY_ENV,
23
+ } from "./githubDeployKey";
24
+ import { ensureReleaseHistory, getLastReleaseTag, git } from "./releaseGit";
26
25
  import { createReleaseEntry } from "./releaseEntry";
27
26
  import { appendStepSummary } from "./stepSummary";
28
27
 
@@ -82,12 +81,11 @@ const requireEnv = (name: string): string => {
82
81
  * - gitlab: CI checkouts have a read-only origin, so push via an
83
82
  * authenticated url with GL_TOKEN (the same project access token
84
83
  * semantic-release uses)
85
- * - github: over ssh with the release deploy key
86
- * (CATLADDER_RELEASE_KEY, provisioned by `project setup`) merge
87
- * gating requires the `catladder ✅` check on the default branch,
88
- * and only a deploy key can bypass that (github deliberately never
89
- * lets the workflow token bypass rulesets). Without the secret the
90
- * push falls back to origin, which works on repos without gating.
84
+ * - github: over ssh with the release deploy key (see
85
+ * ./githubDeployKey) merge gating requires the `catladder ✅` check
86
+ * on the default branch, and only a deploy key can bypass that.
87
+ * Without the secret the push falls back to origin (the workflow
88
+ * token), which works on repos without gating.
91
89
  */
92
90
  const pushCommitAndTag = async (tag: string) => {
93
91
  // --atomic: all refs or none — a rejected tag must not leave the
@@ -96,7 +94,7 @@ const pushCommitAndTag = async (tag: string) => {
96
94
  if (process.env.GITHUB_ACTIONS === "true") {
97
95
  const branch = requireEnv("GITHUB_REF_NAME");
98
96
  const refs = [`HEAD:refs/heads/${branch}`, tag];
99
- const deployKey = process.env.CATLADDER_RELEASE_KEY;
97
+ const deployKey = getReleaseDeployKey();
100
98
  if (deployKey) {
101
99
  await pushWithDeployKey(deployKey, refs);
102
100
  return;
@@ -104,16 +102,21 @@ const pushCommitAndTag = async (tag: string) => {
104
102
  try {
105
103
  await git("push", "--atomic", "origin", ...refs);
106
104
  } catch (e) {
107
- // GH006: a required status check rejects the fresh release
108
- // commit (it cannot have a passing check yet) — the push needs
109
- // the release deploy key, which `project setup` provisions
110
- if (/GH006|protected branch/i.test(`${e?.message ?? e}`)) {
105
+ // GH006 (classic protection) / GH013 (ruleset): a required status
106
+ // check rejects the fresh release commit (it cannot have a
107
+ // passing check yet) — the push needs the release deploy key,
108
+ // which `project setup` provisions
109
+ if (
110
+ /GH006|GH013|protected branch|rule violations/i.test(
111
+ `${e?.message ?? e}`,
112
+ )
113
+ ) {
111
114
  throw new Error(
112
115
  `pushing the release to '${branch}' was rejected by its branch protection:\n` +
113
116
  `${e.message}\n` +
114
117
  `The workflow token cannot bypass a required status check on '${branch}'. ` +
115
118
  `Run \`catladder project setup\` to provision the release deploy key ` +
116
- `(${RELEASE_KEY_ENV} secret + ruleset bypass), then rerun this job.`,
119
+ `(${RELEASE_DEPLOY_KEY_ENV} secret + ruleset bypass), then rerun this job.`,
117
120
  );
118
121
  }
119
122
  throw e;
@@ -128,41 +131,6 @@ const pushCommitAndTag = async (tag: string) => {
128
131
  await git("push", "--atomic", remote, `HEAD:refs/heads/${branch}`, tag);
129
132
  };
130
133
 
131
- const RELEASE_KEY_ENV = "CATLADDER_RELEASE_KEY";
132
-
133
- /**
134
- * pushes over ssh with the release deploy key — the only actor that
135
- * can bypass the merge-gating ruleset on the default branch
136
- */
137
- const pushWithDeployKey = async (privateKey: string, refs: string[]) => {
138
- const serverUrl = process.env.GITHUB_SERVER_URL ?? "https://github.com";
139
- const host = new URL(serverUrl).host;
140
- const repository = requireEnv("GITHUB_REPOSITORY");
141
- const dir = await mkdtemp(join(tmpdir(), "release-key-"));
142
- try {
143
- const keyFile = join(dir, "id_ed25519");
144
- // a key file without a trailing newline is rejected by openssh
145
- await writeFile(
146
- keyFile,
147
- privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
148
- {
149
- mode: 0o600,
150
- },
151
- );
152
- await gitWithEnv(
153
- {
154
- GIT_SSH_COMMAND: `ssh -i ${keyFile} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`,
155
- },
156
- "push",
157
- "--atomic",
158
- `git@${host}:${repository}.git`,
159
- ...refs,
160
- );
161
- } finally {
162
- await rm(dir, { recursive: true, force: true });
163
- }
164
- };
165
-
166
134
  /**
167
135
  * tags pushed with the default GITHUB_TOKEN do not trigger `on: push:
168
136
  * tags` workflows (github's recursion guard), so the taggedRelease
@@ -267,9 +235,12 @@ export const changesetsReleaseJob = async () => {
267
235
  // [skip ci] on github only: deploy-key pushes trigger workflows
268
236
  // (unlike GITHUB_TOKEN pushes), and neither the release commit nor
269
237
  // the tag needs a push-triggered run — the taggedRelease workflow is
270
- // dispatched explicitly below, [skip ci] keeps it the only run. On
271
- // gitlab the tag pipeline MUST fire natively, so no marker there
272
- // (release-commit branch pipelines are already filtered by rules).
238
+ // dispatched explicitly below, [skip ci] keeps it the only run
239
+ // (verified on catladder's own releases: one run per tag, all
240
+ // workflow_dispatch). The semantic-release runner script does the
241
+ // same. On gitlab the tag pipeline MUST fire natively, so no marker
242
+ // there (release-commit branch pipelines are already filtered by
243
+ // rules).
273
244
  const onGithub = process.env.GITHUB_ACTIONS === "true";
274
245
  const subject = `chore(release): ${version}${onGithub ? " [skip ci]" : ""}`;
275
246
  await git("commit", "-m", `${subject}\n\n${entries}`);