polyci 0.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/dist/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import main from "./main.js";
3
+ main();
package/dist/main.js ADDED
@@ -0,0 +1,323 @@
1
+ import { Command } from "commander";
2
+ import { execSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import pino from "pino";
6
+ import pretty from "pino-pretty";
7
+ const log = pino(pretty());
8
+ function exec(command, cwd) {
9
+ return execSync(command, {
10
+ encoding: "utf8",
11
+ cwd: cwd ?? process.cwd(),
12
+ }).trim();
13
+ }
14
+ const BRANCH_RULE = "/^(main)|(develop)|(release(-[a-z0-9]+)*)|(feature(-[a-z0-9]+)*)|(patch(-[a-z0-9]+)*)$/";
15
+ const IGNORED_DIRS = new Set(["node_modules", ".git", "dist"]);
16
+ function parseArgs() {
17
+ const program = new Command();
18
+ program
19
+ .argument("[output]", "Output pipeline file path (or use --output)")
20
+ .option("-o, --output <path>", "Output pipeline file path")
21
+ .option("--cwd <path>", "Working directory", process.cwd())
22
+ .parse();
23
+ const options = program.opts();
24
+ const output = options.output ?? program.args[0] ?? "";
25
+ const cwd = path.resolve(options.cwd);
26
+ return { cwd, output };
27
+ }
28
+ function toPosixPath(p) {
29
+ return p.split(path.sep).join("/");
30
+ }
31
+ function toJobId(value) {
32
+ return value
33
+ .replace(/[^a-zA-Z0-9_]/g, "_")
34
+ .replace(/_+/g, "_")
35
+ .replace(/^_+|_+$/g, "")
36
+ .toLowerCase();
37
+ }
38
+ function detectModuleType(directory) {
39
+ const packageJson = path.join(directory, "package.json");
40
+ const viteConfig = path.join(directory, "vite.config.ts");
41
+ if (fs.existsSync(packageJson) && fs.existsSync(viteConfig)) {
42
+ return "node-vite";
43
+ }
44
+ if (!fs.existsSync(packageJson)) {
45
+ return null;
46
+ }
47
+ try {
48
+ const packageData = fs.readFileSync(packageJson, "utf8");
49
+ const packageJsonParsed = JSON.parse(packageData);
50
+ if (packageJsonParsed.dependencies?.express !== undefined) {
51
+ return "node-express";
52
+ }
53
+ }
54
+ catch (error) {
55
+ log.warn({ directory, error }, "Unable to read package.json while detecting module type");
56
+ }
57
+ return null;
58
+ }
59
+ function discoverModulesRecursively(scanDir, repoRoot, modules) {
60
+ const moduleType = detectModuleType(scanDir);
61
+ if (moduleType !== null) {
62
+ const relativeModulePath = toPosixPath(path.relative(repoRoot, scanDir));
63
+ const moduleName = path.basename(scanDir);
64
+ modules.push({
65
+ moduleName,
66
+ modulePath: relativeModulePath,
67
+ moduleType,
68
+ jobId: toJobId(relativeModulePath),
69
+ });
70
+ return;
71
+ }
72
+ const entries = fs.readdirSync(scanDir, { withFileTypes: true });
73
+ for (const entry of entries) {
74
+ if (!entry.isDirectory())
75
+ continue;
76
+ if (IGNORED_DIRS.has(entry.name))
77
+ continue;
78
+ discoverModulesRecursively(path.join(scanDir, entry.name), repoRoot, modules);
79
+ }
80
+ }
81
+ function discoverModules(repoRoot) {
82
+ const modulesRoot = path.join(repoRoot, "modules");
83
+ if (!fs.existsSync(modulesRoot) || !fs.statSync(modulesRoot).isDirectory()) {
84
+ return [];
85
+ }
86
+ const modules = [];
87
+ discoverModulesRecursively(modulesRoot, repoRoot, modules);
88
+ modules.sort((a, b) => a.modulePath.localeCompare(b.modulePath));
89
+ return modules;
90
+ }
91
+ function appendGlobalVariables(lines) {
92
+ lines.push("variables:");
93
+ lines.push(" IMAGE_LINUX: alpine:latest #alpine:3.23.3");
94
+ lines.push(" IMAGE_NODE: node:alpine #node:25.6.0-alpine3.23");
95
+ lines.push(" IMAGE_GIT: alpine/git:latest");
96
+ lines.push(" IMAGE_NGINX: nginx:alpine-slim #nginx:1.29.5-alpine3.23-slim");
97
+ lines.push(" IMAGE_DOCKER: docker:latest #docker:29.2.1-dind-alpine3.23");
98
+ lines.push(" IMAGE_COMPOSE: docker:cli");
99
+ lines.push(" GIT_PACKAGE: git #git=2.47.2-r0");
100
+ lines.push("");
101
+ lines.push("stages:");
102
+ lines.push(" - build");
103
+ lines.push(" - test");
104
+ lines.push(" - release");
105
+ lines.push(" - publish");
106
+ lines.push(" - deploy");
107
+ lines.push("");
108
+ }
109
+ function appendRulesForModule(lines, modulePath) {
110
+ lines.push(" rules:");
111
+ lines.push(" - if: $CI_COMMIT_MESSAGE =~ /^release:/");
112
+ lines.push(" when: never");
113
+ lines.push(` - if: $CI_COMMIT_BRANCH =~ ${BRANCH_RULE}`);
114
+ lines.push(" changes:");
115
+ lines.push(` - ${modulePath}/**/*`);
116
+ }
117
+ function appendModuleBuildJob(lines, module) {
118
+ lines.push(`${module.jobId}_build:`);
119
+ lines.push(" stage: build");
120
+ appendRulesForModule(lines, module.modulePath);
121
+ lines.push(" image: $IMAGE_NODE");
122
+ lines.push(" variables:");
123
+ lines.push(` MODULE_NAME: ${module.moduleName}`);
124
+ lines.push(` MODULE_PATH: ${module.modulePath}`);
125
+ lines.push(` MODULE_TYPE: ${module.moduleType}`);
126
+ lines.push(" script:");
127
+ lines.push(" - cd $MODULE_PATH");
128
+ lines.push(" - npm ci");
129
+ lines.push(" - npm run build");
130
+ lines.push(" artifacts:");
131
+ lines.push(" paths:");
132
+ lines.push(" - $MODULE_PATH/dist/");
133
+ lines.push(" expire_in: 1 day");
134
+ lines.push("");
135
+ }
136
+ function appendModuleTestJob(lines, module) {
137
+ lines.push(`${module.jobId}_test:`);
138
+ lines.push(" stage: test");
139
+ appendRulesForModule(lines, module.modulePath);
140
+ lines.push(" needs:");
141
+ lines.push(` - job: ${module.jobId}_build`);
142
+ lines.push(" optional: true");
143
+ lines.push(" image: $IMAGE_LINUX");
144
+ lines.push(" variables:");
145
+ lines.push(` MODULE_NAME: ${module.moduleName}`);
146
+ lines.push(` MODULE_PATH: ${module.modulePath}`);
147
+ lines.push(` MODULE_TYPE: ${module.moduleType}`);
148
+ lines.push(" script:");
149
+ lines.push(" - cd $MODULE_PATH");
150
+ lines.push(' - echo "test is tasty"');
151
+ lines.push("");
152
+ }
153
+ function appendGlobalReleaseJob(lines, modules) {
154
+ lines.push("release_and_tag:");
155
+ lines.push(" stage: release");
156
+ lines.push(" image: $IMAGE_NODE");
157
+ lines.push(" variables:");
158
+ lines.push(" GITLAB_TOKEN: $SEMANTIC_RELEASE_TOKEN # Used to push release commits/tags.");
159
+ lines.push(" script:");
160
+ lines.push(" - apk update");
161
+ lines.push(" - apk add $GIT_PACKAGE");
162
+ lines.push(" - |");
163
+ lines.push(" set -euo pipefail");
164
+ lines.push(" git config user.email \"ci-release@local\"");
165
+ lines.push(" git config user.name \"CI Release\"");
166
+ lines.push(" git remote set-url origin \"https://oauth2:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git\"");
167
+ lines.push("");
168
+ lines.push(" if [ -n \"${CI_COMMIT_BEFORE_SHA:-}\" ] && [ \"${CI_COMMIT_BEFORE_SHA}\" != \"0000000000000000000000000000000000000000\" ]; then");
169
+ lines.push(" CHANGED_FILES=$(git diff --name-only \"${CI_COMMIT_BEFORE_SHA}\" \"${CI_COMMIT_SHA}\")");
170
+ lines.push(" else");
171
+ lines.push(" CHANGED_FILES=$(git show --pretty='' --name-only \"${CI_COMMIT_SHA}\")");
172
+ lines.push(" fi");
173
+ lines.push("");
174
+ lines.push(" MODULE_MATRIX=$(cat <<'EOF'");
175
+ for (const module of modules) {
176
+ lines.push(` ${module.moduleName}|${module.modulePath}`);
177
+ }
178
+ lines.push(" EOF");
179
+ lines.push(" )");
180
+ lines.push("");
181
+ lines.push(" AFFECTED_MODULES=\"\"");
182
+ lines.push(" while IFS='|' read -r MODULE_NAME MODULE_PATH; do");
183
+ lines.push(" [ -n \"${MODULE_NAME}\" ] || continue");
184
+ lines.push(" if printf '%s\\n' \"${CHANGED_FILES}\" | grep -q \"^${MODULE_PATH}/\"; then");
185
+ lines.push(" AFFECTED_MODULES=\"${AFFECTED_MODULES} ${MODULE_NAME}|${MODULE_PATH}\"");
186
+ lines.push(" fi");
187
+ lines.push(" done <<'EOF'");
188
+ for (const module of modules) {
189
+ lines.push(` ${module.moduleName}|${module.modulePath}`);
190
+ }
191
+ lines.push(" EOF");
192
+ lines.push("");
193
+ lines.push(" if [ -z \"${AFFECTED_MODULES}\" ]; then");
194
+ lines.push(" echo \"No affected modules in current commit range\" ");
195
+ lines.push(" exit 0");
196
+ lines.push(" fi");
197
+ lines.push("");
198
+ lines.push(" for entry in ${AFFECTED_MODULES}; do");
199
+ lines.push(" MODULE_NAME=${entry%%|*}");
200
+ lines.push(" MODULE_PATH=${entry#*|}");
201
+ lines.push(" echo \"Tagging release for ${MODULE_NAME} (${MODULE_PATH})\"");
202
+ lines.push(" cd \"${MODULE_PATH}\"");
203
+ lines.push(" npm ci");
204
+ lines.push(" npx semalease semalease.env");
205
+ lines.push(" source semalease.env");
206
+ lines.push(" cd - >/dev/null");
207
+ lines.push("");
208
+ lines.push(" if [ -z \"${NEXT_VERSION:-}\" ]; then");
209
+ lines.push(" echo \"No NEXT_VERSION for ${MODULE_NAME}, skipping tag\"");
210
+ lines.push(" continue");
211
+ lines.push(" fi");
212
+ lines.push("");
213
+ lines.push(" TAG_NAME=\"${MODULE_NAME}/v${NEXT_VERSION}\"");
214
+ lines.push(" if git rev-parse \"${TAG_NAME}\" >/dev/null 2>&1; then");
215
+ lines.push(" echo \"Tag ${TAG_NAME} already exists, skipping\"");
216
+ lines.push(" continue");
217
+ lines.push(" fi");
218
+ lines.push(" git tag \"${TAG_NAME}\"");
219
+ lines.push(" done");
220
+ lines.push("");
221
+ lines.push(" if [ -n \"$(git status --porcelain)\" ]; then");
222
+ lines.push(" git add -A");
223
+ lines.push(" git commit -m \"release: update affected modules\"");
224
+ lines.push(" fi");
225
+ lines.push("");
226
+ lines.push(" git push origin HEAD");
227
+ lines.push(" git push origin --tags");
228
+ lines.push("");
229
+ }
230
+ function appendModulePublishJob(lines, module) {
231
+ lines.push(`${module.jobId}_publish:`);
232
+ lines.push(" stage: publish");
233
+ appendRulesForModule(lines, module.modulePath);
234
+ lines.push(" needs:");
235
+ lines.push(` - job: ${module.jobId}_test`);
236
+ lines.push(" optional: true");
237
+ lines.push(" - job: release_and_tag");
238
+ lines.push(" optional: true");
239
+ lines.push(" image: $IMAGE_DOCKER");
240
+ lines.push(" variables:");
241
+ lines.push(` MODULE_NAME: ${module.moduleName}`);
242
+ lines.push(` MODULE_PATH: ${module.modulePath}`);
243
+ lines.push(` MODULE_TYPE: ${module.moduleType}`);
244
+ lines.push(" script:");
245
+ lines.push(" - cd $MODULE_PATH");
246
+ lines.push(" - npm ci");
247
+ lines.push(" - npx semalease semalease.env");
248
+ lines.push(" - source semalease.env");
249
+ lines.push(" - node ../ci/set-release-data.js -i $CI_COMMIT_BRANCH --version=${NEXT_VERSION} -t");
250
+ lines.push(" - npm run build");
251
+ lines.push(" - node ci/prepare-deploy-variables.js");
252
+ lines.push(" - echo -n $CI_JOB_TOKEN | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY");
253
+ lines.push(" - cp ../ci/$MODULE_TYPE/Dockerfile .");
254
+ lines.push(" - docker build --tag $CI_REGISTRY_IMAGE/$MODULE_NAME:v$INSTANCE_VERSION .");
255
+ lines.push(" - docker push $CI_REGISTRY_IMAGE/$MODULE_NAME:v$INSTANCE_VERSION");
256
+ lines.push(" artifacts:");
257
+ lines.push(" reports:");
258
+ lines.push(" dotenv: $MODULE_PATH/.env.deploy");
259
+ lines.push(" expire_in: 1 day");
260
+ lines.push("");
261
+ }
262
+ function appendModuleDeployJob(lines, module) {
263
+ lines.push(`${module.jobId}_deploy:`);
264
+ lines.push(" stage: deploy");
265
+ appendRulesForModule(lines, module.modulePath);
266
+ lines.push(" needs:");
267
+ lines.push(` - job: ${module.jobId}_publish`);
268
+ lines.push(" optional: true");
269
+ lines.push(" image: $IMAGE_COMPOSE");
270
+ lines.push(" variables:");
271
+ lines.push(` MODULE_NAME: ${module.moduleName}`);
272
+ lines.push(` MODULE_PATH: ${module.modulePath}`);
273
+ lines.push(` MODULE_TYPE: ${module.moduleType}`);
274
+ lines.push(" script:");
275
+ lines.push(" - apk add --update --no-cache openssh");
276
+ lines.push(" - eval $(ssh-agent -s)");
277
+ lines.push(' - echo "$DEPLOY_USER_KEY" | tr -d \'\\r\' | ssh-add -');
278
+ lines.push(" - mkdir -p ~/.ssh");
279
+ lines.push(" - chmod 700 ~/.ssh");
280
+ lines.push(" - ssh-keyscan -p $DEPLOY_PORT $DEPLOY_ADDRESS >> ~/.ssh/known_hosts");
281
+ lines.push(" - chmod 644 ~/.ssh/known_hosts");
282
+ lines.push(" - echo -n $CI_JOB_TOKEN | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY");
283
+ lines.push(' - docker context create remote --docker "host=ssh://$DEPLOY_USER@$DEPLOY_ADDRESS:$DEPLOY_PORT"');
284
+ lines.push(" - docker context use remote");
285
+ lines.push(" - cd ci");
286
+ lines.push(" - docker compose -p $INSTANCE_CONTAINER down --remove-orphans");
287
+ lines.push(" - docker compose pull");
288
+ lines.push(" - docker compose -p $INSTANCE_CONTAINER up -d");
289
+ lines.push(" - rm -rf ~/.ssh");
290
+ lines.push("");
291
+ }
292
+ function buildPipeline(modules) {
293
+ const lines = [];
294
+ appendGlobalVariables(lines);
295
+ for (const module of modules) {
296
+ appendModuleBuildJob(lines, module);
297
+ appendModuleTestJob(lines, module);
298
+ }
299
+ appendGlobalReleaseJob(lines, modules);
300
+ for (const module of modules) {
301
+ appendModulePublishJob(lines, module);
302
+ appendModuleDeployJob(lines, module);
303
+ }
304
+ return `${lines.join("\n").trimEnd()}\n`;
305
+ }
306
+ function main() {
307
+ const { cwd, output } = parseArgs();
308
+ if (!output) {
309
+ log.error("Output path is required. Use [output] or --output <path>.");
310
+ process.exit(1);
311
+ }
312
+ const modules = discoverModules(cwd);
313
+ if (modules.length === 0) {
314
+ log.error({ cwd }, "No supported modules were discovered under ./modules");
315
+ process.exit(1);
316
+ }
317
+ const pipeline = buildPipeline(modules);
318
+ const outputPath = path.resolve(cwd, output);
319
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
320
+ fs.writeFileSync(outputPath, pipeline, "utf8");
321
+ log.info({ modules: modules.map((m) => m.modulePath), outputPath }, "Generated GitLab pipeline");
322
+ }
323
+ export default main;
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "polyci",
3
+ "description": "Monorepo CI/CD utilities.",
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "private": false,
7
+ "author": "Alexander Tsarev",
8
+ "license": "MIT",
9
+ "main": "./dist/main.js",
10
+ "bin": "./dist/cli.js",
11
+ "files": ["dist", "readme.md"],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "dev": "tsx main.ts"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^14.0.3",
21
+ "pino": "^10.3.1",
22
+ "pino-pretty": "^13.1.3"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^25.3.3",
26
+ "tsx": "^4.21.0",
27
+ "typescript": "^5.9.3"
28
+ }
29
+ }
package/readme.md ADDED
File without changes