Skip to content

Commit a979a0d

Browse files
committed
feat(sisyphus): add GitLab CI support to actions init command
1 parent 3d18eb6 commit a979a0d

10 files changed

Lines changed: 191 additions & 71 deletions

File tree

packages/sisyphus/build.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { cp } from "node:fs/promises";
12
import { bunPackageBuilder } from "@r5n/tools/builder";
23

34
await bunPackageBuilder({ banner: "#!/usr/bin/env bun", maxSize: 150, packages: "bundle", target: "bun", type: "cli" });
5+
6+
await cp("src/commands/actions/templates", "dist/templates", { recursive: true });
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { existsSync } from "node:fs";
2+
import { readFile } from "node:fs/promises";
3+
import { dirname, join } from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
import type { Provider } from "../../providers";
6+
7+
export type WorkflowType = "create-stone" | "release-pr" | "release";
8+
9+
export type WorkflowFile = { path: string; content: string };
10+
11+
export type WorkflowConfig = { name: string; description: string };
12+
13+
export const WORKFLOW_CONFIGS: Record<WorkflowType, WorkflowConfig> = {
14+
"create-stone": {
15+
description: "When a PR is merged, automatically create a stone",
16+
name: "Auto-create stone from PR",
17+
},
18+
release: { description: "When the release PR is merged, run the release", name: "Auto-release on PR merge" },
19+
"release-pr": {
20+
description: "When stones are added, create or update a release PR",
21+
name: "Create/update release PR",
22+
},
23+
};
24+
25+
const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "templates");
26+
27+
const TEMPLATE_FILENAMES: Record<WorkflowType, string> = {
28+
"create-stone": "sis-create-stone.yml",
29+
release: "sis-release.yml",
30+
"release-pr": "sis-release-pr.yml",
31+
};
32+
33+
export abstract class CiGenerator {
34+
abstract readonly provider: Provider;
35+
protected abstract readonly outputDir: string;
36+
37+
async generate(workflows: WorkflowType[]): Promise<WorkflowFile[]> {
38+
const files: WorkflowFile[] = [];
39+
40+
for (const type of workflows) {
41+
const content = await this.readTemplate(type);
42+
const path = this.getOutputPath(type);
43+
files.push({ content, path });
44+
}
45+
46+
return files;
47+
}
48+
49+
getExistingFiles(workflows: WorkflowType[]): string[] {
50+
return workflows.map((type) => this.getOutputPath(type)).filter((path) => existsSync(path));
51+
}
52+
53+
protected async readTemplate(type: WorkflowType): Promise<string> {
54+
const templatePath = join(TEMPLATES_DIR, this.provider, TEMPLATE_FILENAMES[type]);
55+
return readFile(templatePath, "utf-8");
56+
}
57+
58+
protected abstract getOutputPath(type: WorkflowType): string;
59+
}
60+
61+
export class GitHubCiGenerator extends CiGenerator {
62+
readonly provider = "github" as const;
63+
protected readonly outputDir = ".github/workflows";
64+
65+
protected getOutputPath(type: WorkflowType): string {
66+
return join(this.outputDir, TEMPLATE_FILENAMES[type]);
67+
}
68+
}
69+
70+
const GITLAB_SISYPHUS_FILE = ".gitlab-ci-sisyphus.yml";
71+
72+
export class GitLabCiGenerator extends CiGenerator {
73+
readonly provider = "gitlab" as const;
74+
protected readonly outputDir = ".";
75+
76+
async generate(workflows: WorkflowType[]): Promise<WorkflowFile[]> {
77+
const sections: string[] = [];
78+
79+
sections.push("stages:\n - sisyphus\n");
80+
81+
for (const type of workflows) {
82+
const content = await this.readTemplate(type);
83+
sections.push(content);
84+
}
85+
86+
return [{ content: sections.join("\n"), path: GITLAB_SISYPHUS_FILE }];
87+
}
88+
89+
getExistingFiles(_workflows: WorkflowType[]): string[] {
90+
return existsSync(GITLAB_SISYPHUS_FILE) ? [GITLAB_SISYPHUS_FILE] : [];
91+
}
92+
93+
getIncludeInstruction(): string {
94+
return `include:\n - local: '${GITLAB_SISYPHUS_FILE}'`;
95+
}
96+
97+
protected getOutputPath(_type: WorkflowType): string {
98+
return GITLAB_SISYPHUS_FILE;
99+
}
100+
}
101+
102+
export function createCiGenerator(provider: Provider): CiGenerator {
103+
switch (provider) {
104+
case "github":
105+
return new GitHubCiGenerator();
106+
case "gitlab":
107+
return new GitLabCiGenerator();
108+
case "bitbucket":
109+
throw new Error("Bitbucket CI generation is not supported yet");
110+
}
111+
}
Lines changed: 38 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,12 @@
11
import { existsSync } from "node:fs";
2-
import { mkdir, readFile, writeFile } from "node:fs/promises";
3-
import { dirname, join } from "node:path";
4-
import { fileURLToPath } from "node:url";
2+
import { mkdir, writeFile } from "node:fs/promises";
3+
import { dirname } from "node:path";
54
import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core";
65
import { BaseCommand, type Ctx } from "../../base-command";
6+
import { detectProvider } from "../../providers";
7+
import { createCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator";
78

8-
const WORKFLOWS_DIR = ".github/workflows";
9-
const TEMPLATES_DIR = join(dirname(fileURLToPath(import.meta.url)), "workflows");
10-
11-
type WorkflowType = "create-stone" | "release-pr" | "release";
12-
13-
type WorkflowConfig = { name: string; description: string; filename: string };
14-
15-
const WORKFLOWS: Record<WorkflowType, WorkflowConfig> = {
16-
"create-stone": {
17-
description: "When a PR is merged, automatically create a stone",
18-
filename: "sis-create-stone.yml",
19-
name: "Auto-create stone from PR",
20-
},
21-
release: {
22-
description: "When the release PR is merged, run the release",
23-
filename: "sis-release.yml",
24-
name: "Auto-release on PR merge",
25-
},
26-
"release-pr": {
27-
description: "When stones are added, create or update a release PR",
28-
filename: "sis-release-pr.yml",
29-
name: "Create/update release PR",
30-
},
31-
};
9+
const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" };
3210

3311
const initArgs = args({
3412
all: { alias: "a", default: false, description: "Install all workflows", type: "boolean" },
@@ -43,55 +21,74 @@ type InitCtx = Ctx<typeof initArgs>;
4321

4422
export class ActionsInitCommand extends BaseCommand {
4523
name = "init";
46-
description = "Set up GitHub Actions workflows";
24+
description = "Set up CI workflows for automated releases";
4725
args = initArgs;
4826
prompts = true;
4927

5028
async execute(ctx: InitCtx) {
29+
const provider = await detectProvider();
30+
const generator = createCiGenerator(provider);
31+
32+
log.info(`Detected ${color.bold(PROVIDER_LABELS[provider])} repository\n`);
33+
5134
const selected = await this.selectWorkflows(ctx);
5235

5336
if (selected.length === 0) {
5437
log.info(color.dim("No workflows selected"));
5538
return;
5639
}
5740

58-
const existing = this.findExistingWorkflows(selected);
41+
const existing = generator.getExistingFiles(selected);
5942
if (existing.length > 0 && !ctx.args.yes) {
6043
if (!ctx.interactive) {
6144
throw new Exit(`Workflows already exist: ${existing.join(", ")}`, "Use --yes to overwrite existing workflows");
6245
}
63-
log.warn(`The following workflows already exist:\n${existing.map((f) => ` ${f}`).join("\n")}`);
64-
const overwrite = await confirm({ initialValue: false, message: "Overwrite existing workflows?" });
46+
log.warn(`The following files already exist:\n${existing.map((f) => ` ${f}`).join("\n")}`);
47+
const overwrite = await confirm({ initialValue: false, message: "Overwrite existing files?" });
6548
if (!overwrite) return;
6649
}
6750

51+
const files = await generator.generate(selected);
52+
6853
if (ctx.args.dryRun) {
69-
await this.previewWorkflows(selected);
54+
for (const file of files) {
55+
log.info(`\n${color.bold(file.path)}:`);
56+
log.info(color.dim(file.content));
57+
}
7058
return;
7159
}
7260

73-
await this.createWorkflows(selected);
61+
for (const file of files) {
62+
const dir = dirname(file.path);
63+
if (dir !== "." && !existsSync(dir)) {
64+
await mkdir(dir, { recursive: true });
65+
}
66+
await writeFile(file.path, file.content, "utf-8");
67+
}
68+
69+
note(files.map((f) => ` ${color.green("+")} ${f.path}`).join("\n"), color.green("CI workflows created"));
7470

75-
note(
76-
selected.map((w) => ` ${color.green("+")} ${WORKFLOWS[w].filename}`).join("\n"),
77-
color.green("Workflows created"),
78-
);
71+
if (generator instanceof GitLabCiGenerator) {
72+
log.info(`\nAdd this to your ${color.bold(".gitlab-ci.yml")}:\n`);
73+
log.info(color.cyan(generator.getIncludeInstruction()));
74+
log.info("");
75+
}
7976

80-
log.info(color.dim("\nCommit and push these files to enable the workflows."));
77+
log.info(color.dim("Commit and push these files to enable the workflows."));
8178
}
8279

8380
private async selectWorkflows(ctx: InitCtx): Promise<WorkflowType[]> {
8481
const fromFlags = this.getWorkflowsFromFlags(ctx);
8582
if (fromFlags.length > 0) return fromFlags;
8683

8784
if (ctx.args.all || !ctx.interactive) {
88-
return Object.keys(WORKFLOWS) as WorkflowType[];
85+
return Object.keys(WORKFLOW_CONFIGS) as WorkflowType[];
8986
}
9087

9188
return multiselect({
92-
initialValues: Object.keys(WORKFLOWS) as WorkflowType[],
89+
initialValues: Object.keys(WORKFLOW_CONFIGS) as WorkflowType[],
9390
message: "Select workflows to create",
94-
options: Object.entries(WORKFLOWS).map(([key, config]) => ({
91+
options: Object.entries(WORKFLOW_CONFIGS).map(([key, config]) => ({
9592
hint: config.description,
9693
label: config.name,
9794
value: key as WorkflowType,
@@ -107,34 +104,4 @@ export class ActionsInitCommand extends BaseCommand {
107104
if (ctx.args.release) selected.push("release");
108105
return selected;
109106
}
110-
111-
private findExistingWorkflows(selected: WorkflowType[]): string[] {
112-
return selected.map((w) => WORKFLOWS[w].filename).filter((filename) => existsSync(join(WORKFLOWS_DIR, filename)));
113-
}
114-
115-
private async readTemplate(filename: string): Promise<string> {
116-
return readFile(join(TEMPLATES_DIR, filename), "utf-8");
117-
}
118-
119-
private async previewWorkflows(selected: WorkflowType[]) {
120-
for (const workflow of selected) {
121-
const config = WORKFLOWS[workflow];
122-
const content = await this.readTemplate(config.filename);
123-
log.info(`\n${color.bold(config.filename)}:`);
124-
log.info(color.dim(content));
125-
}
126-
}
127-
128-
private async createWorkflows(selected: WorkflowType[]) {
129-
if (!existsSync(WORKFLOWS_DIR)) {
130-
await mkdir(WORKFLOWS_DIR, { recursive: true });
131-
}
132-
133-
for (const workflow of selected) {
134-
const config = WORKFLOWS[workflow];
135-
const content = await this.readTemplate(config.filename);
136-
const filepath = join(WORKFLOWS_DIR, config.filename);
137-
await writeFile(filepath, content, "utf-8");
138-
}
139-
}
140107
}

packages/sisyphus/src/commands/actions/workflows/sis-create-stone.yml renamed to packages/sisyphus/src/commands/actions/templates/github/sis-create-stone.yml

File renamed without changes.

packages/sisyphus/src/commands/actions/workflows/sis-release-pr.yml renamed to packages/sisyphus/src/commands/actions/templates/github/sis-release-pr.yml

File renamed without changes.

packages/sisyphus/src/commands/actions/workflows/sis-release.yml renamed to packages/sisyphus/src/commands/actions/templates/github/sis-release.yml

File renamed without changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
create-stone:
2+
stage: sisyphus
3+
image: oven/bun:latest
4+
rules:
5+
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged"
6+
script:
7+
- bun install
8+
- bunx @r5n/sisyphus pr --url $CI_MERGE_REQUEST_PROJECT_URL/-/merge_requests/$CI_MERGE_REQUEST_IID --yes
9+
- git config user.name "gitlab-ci[bot]"
10+
- git config user.email "gitlab-ci[bot]@users.noreply.gitlab.com"
11+
- git add .sisyphus/stones/
12+
- 'git diff --cached --quiet || git commit -m "chore: add stone from MR !$CI_MERGE_REQUEST_IID"'
13+
- git push https://oauth2:$GITLAB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git HEAD:$CI_DEFAULT_BRANCH
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
release-pr:
2+
stage: sisyphus
3+
image: oven/bun:latest
4+
rules:
5+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
6+
changes:
7+
- .sisyphus/stones/**/*
8+
script:
9+
- bun install
10+
- bunx @r5n/sisyphus actions release-pr
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
release:
2+
stage: sisyphus
3+
image: oven/bun:latest
4+
rules:
5+
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "merged" && $CI_MERGE_REQUEST_LABELS =~ /sisyphus-release/
6+
script:
7+
- bun install
8+
- bunx @r5n/sisyphus roll --yes

packages/sisyphus/src/providers/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ async function detectRemoteInfo(): Promise<RemoteInfo | null> {
4040
return parseRemoteUrl(url);
4141
}
4242

43+
export async function detectProvider(): Promise<Provider> {
44+
const info = await detectRemoteInfo();
45+
if (!info) {
46+
throw new Exit("Could not detect git provider", "Make sure you have a git remote configured (origin)");
47+
}
48+
return info.provider;
49+
}
50+
4351
async function getRemoteUrl(): Promise<string | null> {
4452
try {
4553
const result = await Bun.$`git remote get-url origin`.quiet();

0 commit comments

Comments
 (0)