@catladder/cli 5.1.2 → 5.2.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/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.d.ts +30 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.js +89 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.js.map +1 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +2 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.d.ts +4 -1
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +4 -1
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js.map +1 -1
- package/dist/apps/cli/src/catci.js +13 -0
- package/dist/apps/cli/src/catci.js.map +1 -1
- package/dist/apps/cli/src/release/changesetsReleaseJob.js +20 -42
- package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
- package/dist/apps/cli/src/release/githubDeployKey.d.ts +36 -0
- package/dist/apps/cli/src/release/githubDeployKey.js +126 -0
- package/dist/apps/cli/src/release/githubDeployKey.js.map +1 -0
- package/dist/bundles/catci/index.js +5 -5
- package/dist/bundles/catenv/index.js +41 -3
- package/dist/bundles/cli/index.js +143 -4
- package/dist/bundles/runner-images/semantic-release/Dockerfile +3 -1
- package/dist/bundles/runner-images/semantic-release/scripts/semanticRelease +37 -4
- package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +15 -6
- package/dist/bundles/skills/catladder-pipelines/SKILL.md +32 -0
- package/dist/bundles/skills/catladder-releases/SKILL.md +14 -0
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js +12 -3
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +16 -0
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
- package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.d.ts +17 -0
- package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.js +13 -0
- package/dist/packages/pipeline/src/backends/gitlab/gitlabReleaseJobs.js.map +1 -1
- package/dist/packages/pipeline/src/types/gitlab-types.d.ts +29 -1
- package/dist/runner-images/semantic-release/Dockerfile +3 -1
- package/dist/runner-images/semantic-release/scripts/semanticRelease +37 -4
- package/dist/skills/catladder-migrate-ci-backend/SKILL.md +15 -6
- package/dist/skills/catladder-pipelines/SKILL.md +32 -0
- package/dist/skills/catladder-releases/SKILL.md +14 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/cli/commands/project/__tests__/checkGithubReleaseWorkflow.test.ts +89 -0
- package/src/apps/cli/commands/project/doctor/checkGithubReleaseWorkflow.ts +125 -0
- package/src/apps/cli/commands/project/doctor/index.ts +2 -0
- package/src/apps/cli/commands/project/githubMergeGating.ts +4 -1
- package/src/catci.ts +15 -0
- package/src/release/__tests__/githubDeployKey.test.ts +148 -0
- package/src/release/changesetsReleaseJob.ts +28 -57
- package/src/release/githubDeployKey.ts +141 -0
package/package.json
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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 =
|
|
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
|
|
108
|
-
// commit (it cannot have a
|
|
109
|
-
// the release deploy key,
|
|
110
|
-
|
|
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
|
-
`(${
|
|
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
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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}`);
|