Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

actions-importer.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

actions-importer.tsxBlame1464 lines · 1 contributor
bb2dea9Claude1/**
2 * GitHub Actions Importer — stateless YAML converter.
3 *
4 * Routes:
5 * GET /import/actions — landing page with paste/upload form
6 * POST /import/actions — parse & convert, show split-panel results
7 * GET /import/actions/guide — migration guide
8 *
9 * No DB tables needed — the converter is fully stateless.
10 *
11 * Conversion logic:
12 * - actions/checkout@* → stripped (Gluecron clones automatically)
13 * - actions/setup-node@* → NODE_VERSION env var
14 * - actions/setup-python@* → PYTHON_VERSION env var
15 * - actions/setup-java@* → JAVA_VERSION env var
16 * - actions/setup-go@* → GO_VERSION env var
17 * - npm test / run / build → GateTest `run:` step
18 * - ${{ secrets.FOO }} → $FOO
19 * - ${{ github.* }} → stripped / noted as unsupported
20 */
21
22import { Hono } from "hono";
23import { Layout } from "../views/layout";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26
27const actionsImporterRoutes = new Hono<AuthEnv>();
28actionsImporterRoutes.use("*", softAuth);
29
30// ─── Types ───────────────────────────────────────────────────────────────────
31
32interface GateStep {
33 name: string;
34 run: string;
35 env?: Record<string, string>;
36 on: string[];
37}
38
39interface ConversionResult {
40 gates: GateStep[];
41 supported: SupportedMapping[];
42 unsupported: UnsupportedItem[];
43 gatesYaml: string;
44}
45
46interface SupportedMapping {
47 from: string;
48 to: string;
49}
50
51interface UnsupportedItem {
52 action: string;
53 reason: string;
54}
55
56// ─── Scoped CSS (.ai-*) ──────────────────────────────────────────────────────
57
58const importerStyles = `
59 .ai-wrap {
60 max-width: 1320px;
61 margin: 0 auto;
62 padding: var(--space-6) var(--space-4) var(--space-10);
63 }
64
65 /* ─── Hero ─── */
66 .ai-hero {
67 position: relative;
68 margin-bottom: var(--space-8);
69 padding: var(--space-8) var(--space-8);
70 background: var(--bg-elevated);
71 border: 1px solid var(--border);
72 border-radius: 18px;
73 overflow: hidden;
74 text-align: center;
75 }
76 .ai-hero::before {
77 content: '';
78 position: absolute;
79 top: 0; left: 0; right: 0;
80 height: 2px;
6fd5915Claude81 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
bb2dea9Claude82 pointer-events: none;
83 }
84 .ai-hero-orb {
85 position: absolute;
86 top: -80px; right: -80px;
87 width: 320px; height: 320px;
6fd5915Claude88 background: radial-gradient(circle, rgba(91,110,232,0.18) 0%, rgba(95,143,160,0.09) 45%, transparent 70%);
bb2dea9Claude89 filter: blur(60px);
90 pointer-events: none;
91 z-index: 0;
92 }
93 .ai-hero-orb2 {
94 position: absolute;
95 bottom: -60px; left: -60px;
96 width: 260px; height: 260px;
6fd5915Claude97 background: radial-gradient(circle, rgba(95,143,160,0.14) 0%, transparent 70%);
bb2dea9Claude98 filter: blur(55px);
99 pointer-events: none;
100 z-index: 0;
101 }
102 .ai-hero-content { position: relative; z-index: 1; }
103 .ai-eyebrow {
104 display: inline-flex;
105 align-items: center;
106 gap: 8px;
107 font-family: var(--font-mono);
108 font-size: 11px;
109 font-weight: 500;
110 letter-spacing: 0.14em;
111 text-transform: uppercase;
112 color: var(--accent);
113 margin-bottom: var(--space-3);
114 }
115 .ai-hero h1 {
116 font-size: clamp(28px, 4vw, 48px);
117 font-family: var(--font-display);
118 font-weight: 700;
119 letter-spacing: -0.03em;
120 line-height: 1.1;
121 margin-bottom: var(--space-3);
122 color: var(--text-strong);
123 }
124 .ai-hero-sub {
125 font-size: 17px;
126 color: var(--text-muted);
127 max-width: 600px;
128 margin: 0 auto var(--space-6);
129 line-height: 1.6;
130 }
131 .ai-grad {
6fd5915Claude132 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
bb2dea9Claude133 -webkit-background-clip: text;
134 -webkit-text-fill-color: transparent;
135 background-clip: text;
136 }
137
138 /* ─── Form card ─── */
139 .ai-form-card {
140 background: var(--bg-elevated);
141 border: 1px solid var(--border);
142 border-radius: 14px;
143 padding: var(--space-6);
144 margin-bottom: var(--space-6);
145 }
146 .ai-form-label {
147 display: block;
148 font-size: 13px;
149 font-weight: 600;
150 color: var(--text-strong);
151 margin-bottom: var(--space-2);
152 }
153 .ai-form-hint {
154 font-size: 12px;
155 color: var(--text-muted);
156 margin-bottom: var(--space-3);
157 }
158 .ai-textarea {
159 width: 100%;
160 min-height: 280px;
161 padding: var(--space-3) var(--space-4);
162 background: var(--bg-tertiary);
163 border: 1px solid var(--border);
164 border-radius: var(--r-md);
165 color: var(--text);
166 font-family: var(--font-mono);
167 font-size: 13px;
168 line-height: 1.6;
169 resize: vertical;
170 transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
171 }
172 .ai-textarea:focus {
173 outline: none;
174 border-color: var(--border-focus);
175 box-shadow: var(--ring);
176 }
177 .ai-textarea::placeholder { color: var(--text-faint); }
178
179 .ai-upload-zone {
180 border: 2px dashed var(--border);
181 border-radius: var(--r-md);
182 padding: var(--space-5);
183 text-align: center;
184 color: var(--text-muted);
185 font-size: 13px;
186 cursor: pointer;
187 transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
188 margin-top: var(--space-3);
189 }
190 .ai-upload-zone:hover {
191 border-color: var(--accent);
6fd5915Claude192 background: rgba(91,110,232,0.04);
bb2dea9Claude193 }
194 .ai-upload-zone input[type=file] {
195 display: none;
196 }
197
198 .ai-or-sep {
199 display: flex;
200 align-items: center;
201 gap: var(--space-3);
202 margin: var(--space-4) 0;
203 color: var(--text-faint);
204 font-size: 12px;
205 }
206 .ai-or-sep::before, .ai-or-sep::after {
207 content: '';
208 flex: 1;
209 height: 1px;
210 background: var(--border);
211 }
212
213 .ai-submit-row {
214 display: flex;
215 align-items: center;
216 gap: var(--space-3);
217 margin-top: var(--space-5);
218 flex-wrap: wrap;
219 }
220 .ai-submit-row .btn {
221 min-width: 160px;
222 }
223 .ai-note {
224 font-size: 12px;
225 color: var(--text-muted);
226 flex: 1;
227 }
228 .ai-note a { color: var(--text-link); }
229
230 /* ─── Split panel (results) ─── */
231 .ai-split {
232 display: grid;
233 grid-template-columns: 1fr 1fr;
234 gap: var(--space-4);
235 margin-bottom: var(--space-6);
236 }
237 @media (max-width: 900px) {
238 .ai-split { grid-template-columns: 1fr; }
239 }
240 .ai-panel {
241 background: var(--bg-elevated);
242 border: 1px solid var(--border);
243 border-radius: 14px;
244 overflow: hidden;
245 display: flex;
246 flex-direction: column;
247 }
248 .ai-panel-header {
249 display: flex;
250 align-items: center;
251 justify-content: space-between;
252 padding: var(--space-3) var(--space-4);
253 border-bottom: 1px solid var(--border);
254 background: var(--bg-tertiary);
255 gap: var(--space-3);
256 flex-shrink: 0;
257 }
258 .ai-panel-title {
259 font-size: 12px;
260 font-weight: 600;
261 color: var(--text-muted);
262 font-family: var(--font-mono);
263 letter-spacing: 0.05em;
264 text-transform: uppercase;
265 }
266 .ai-panel-badge {
267 font-size: 11px;
268 padding: 2px 8px;
269 border-radius: 9999px;
270 font-weight: 600;
271 background: var(--bg-surface);
272 color: var(--text-muted);
273 border: 1px solid var(--border);
274 font-family: var(--font-mono);
275 }
276 .ai-panel-badge.badge-green {
277 background: rgba(52,211,153,0.1);
278 color: var(--green);
279 border-color: rgba(52,211,153,0.25);
280 }
281 .ai-code-block {
282 flex: 1;
283 margin: 0;
284 padding: var(--space-4);
285 overflow: auto;
286 font-family: var(--font-mono);
287 font-size: 12.5px;
288 line-height: 1.7;
289 color: var(--text);
290 background: transparent;
291 white-space: pre;
292 max-height: 520px;
293 }
294 .ai-panel-actions {
295 padding: var(--space-3) var(--space-4);
296 border-top: 1px solid var(--border);
297 display: flex;
298 gap: var(--space-2);
299 background: var(--bg-tertiary);
300 flex-shrink: 0;
301 flex-wrap: wrap;
302 }
303 .ai-panel-actions .btn {
304 font-size: 12px;
305 padding: 6px 12px;
306 }
307
308 /* ─── Mappings list ─── */
309 .ai-results-grid {
310 display: grid;
311 grid-template-columns: 1fr 1fr;
312 gap: var(--space-4);
313 margin-bottom: var(--space-6);
314 }
315 @media (max-width: 768px) {
316 .ai-results-grid { grid-template-columns: 1fr; }
317 }
318 .ai-card {
319 background: var(--bg-elevated);
320 border: 1px solid var(--border);
321 border-radius: 12px;
322 overflow: hidden;
323 }
324 .ai-card-head {
325 display: flex;
326 align-items: center;
327 gap: var(--space-2);
328 padding: var(--space-3) var(--space-4);
329 border-bottom: 1px solid var(--border);
330 background: var(--bg-tertiary);
331 font-size: 13px;
332 font-weight: 600;
333 color: var(--text-strong);
334 }
335 .ai-card-head svg { flex-shrink: 0; }
336 .ai-card-body { padding: var(--space-3) 0; }
337 .ai-map-row {
338 display: flex;
339 align-items: flex-start;
340 gap: var(--space-3);
341 padding: var(--space-2) var(--space-4);
342 }
343 .ai-map-row:hover { background: var(--bg-hover); }
344 .ai-map-icon {
345 width: 18px;
346 height: 18px;
347 border-radius: 50%;
348 flex-shrink: 0;
349 display: flex;
350 align-items: center;
351 justify-content: center;
352 font-size: 10px;
353 font-weight: 700;
354 margin-top: 1px;
355 }
356 .ai-map-icon.ok { background: rgba(52,211,153,0.15); color: var(--green); }
357 .ai-map-icon.warn { background: rgba(251,191,36,0.15); color: var(--yellow); }
358 .ai-map-from {
359 font-family: var(--font-mono);
360 font-size: 12px;
361 color: var(--text-muted);
362 word-break: break-all;
363 }
364 .ai-map-arrow {
365 color: var(--text-faint);
366 font-size: 11px;
367 flex-shrink: 0;
368 margin-top: 2px;
369 }
370 .ai-map-to {
371 font-family: var(--font-mono);
372 font-size: 12px;
373 color: var(--text-strong);
374 word-break: break-all;
375 }
376 .ai-map-content { flex: 1; }
377 .ai-map-reason {
378 font-size: 11.5px;
379 color: var(--text-muted);
380 margin-top: 2px;
381 }
382
383 /* ─── Empty state ─── */
384 .ai-empty {
385 padding: var(--space-6) var(--space-4);
386 text-align: center;
387 color: var(--text-faint);
388 font-size: 13px;
389 }
390
391 /* ─── Auto-import note ─── */
392 .ai-auto-note {
393 display: flex;
394 align-items: flex-start;
395 gap: var(--space-3);
396 padding: var(--space-4) var(--space-5);
6fd5915Claude397 background: rgba(91,110,232,0.07);
398 border: 1px solid rgba(91,110,232,0.22);
bb2dea9Claude399 border-radius: 10px;
400 margin-bottom: var(--space-6);
401 }
402 .ai-auto-note-icon {
403 width: 32px;
404 height: 32px;
405 border-radius: 8px;
6fd5915Claude406 background: rgba(91,110,232,0.18);
bb2dea9Claude407 display: flex;
408 align-items: center;
409 justify-content: center;
410 flex-shrink: 0;
411 color: var(--accent);
412 }
413 .ai-auto-note-text {
414 font-size: 13.5px;
415 color: var(--text);
416 line-height: 1.55;
417 }
418 .ai-auto-note-text strong { color: var(--text-strong); }
419
420 /* ─── Try again ─── */
421 .ai-try-again {
422 margin-bottom: var(--space-6);
423 display: flex;
424 align-items: center;
425 gap: var(--space-3);
426 }
427
428 /* ─── Guide ─── */
429 .ai-guide-wrap {
430 max-width: 860px;
431 margin: 0 auto;
432 padding: var(--space-6) var(--space-4) var(--space-12);
433 }
434 .ai-guide-wrap h2 {
435 font-size: 20px;
436 margin: var(--space-8) 0 var(--space-3);
437 padding-top: var(--space-4);
438 border-top: 1px solid var(--border);
439 color: var(--text-strong);
440 }
441 .ai-guide-wrap h2:first-of-type { border-top: none; margin-top: var(--space-5); }
442 .ai-guide-wrap p {
443 font-size: 15px;
444 line-height: 1.7;
445 color: var(--text);
446 margin-bottom: var(--space-4);
447 }
448 .ai-guide-wrap ul, .ai-guide-wrap ol {
449 padding-left: var(--space-6);
450 margin-bottom: var(--space-4);
451 }
452 .ai-guide-wrap li {
453 font-size: 15px;
454 line-height: 1.7;
455 color: var(--text);
456 margin-bottom: var(--space-1);
457 }
458 .ai-guide-wrap code {
459 font-family: var(--font-mono);
460 font-size: 13px;
461 background: var(--bg-tertiary);
462 border: 1px solid var(--border-subtle);
463 padding: 2px 6px;
464 border-radius: var(--r-sm);
465 }
466 .ai-guide-pre {
467 background: var(--bg-tertiary);
468 border: 1px solid var(--border);
469 border-radius: var(--r-md);
470 padding: var(--space-4);
471 font-family: var(--font-mono);
472 font-size: 13px;
473 line-height: 1.7;
474 overflow-x: auto;
475 margin-bottom: var(--space-5);
476 white-space: pre;
477 }
478 .ai-step-list {
479 list-style: none;
480 padding: 0;
481 counter-reset: step;
482 display: flex;
483 flex-direction: column;
484 gap: var(--space-4);
485 margin-bottom: var(--space-6);
486 }
487 .ai-step-item {
488 display: flex;
489 gap: var(--space-4);
490 align-items: flex-start;
491 counter-increment: step;
492 }
493 .ai-step-num {
494 width: 28px;
495 height: 28px;
496 border-radius: 50%;
497 background: var(--accent-gradient);
498 color: #fff;
499 font-size: 13px;
500 font-weight: 700;
501 display: flex;
502 align-items: center;
503 justify-content: center;
504 flex-shrink: 0;
505 }
506 .ai-step-body { flex: 1; padding-top: 3px; }
507 .ai-step-title {
508 font-weight: 600;
509 color: var(--text-strong);
510 font-size: 15px;
511 margin-bottom: var(--space-1);
512 }
513 .ai-step-desc {
514 font-size: 13.5px;
515 color: var(--text-muted);
516 line-height: 1.55;
517 }
518
519 /* Buttons */
520 .btn {
521 display: inline-flex;
522 align-items: center;
523 gap: 6px;
524 padding: 9px 16px;
525 border-radius: var(--r);
526 font-size: 14px;
527 font-weight: 600;
528 font-family: var(--font-sans);
529 border: 1px solid transparent;
530 cursor: pointer;
531 transition: all var(--t-fast) var(--ease);
532 text-decoration: none;
533 line-height: 1.3;
534 }
535 .btn-primary {
6fd5915Claude536 background: linear-gradient(135deg, #5b6ee8 0%, #7059e0 100%);
bb2dea9Claude537 color: #fff;
6fd5915Claude538 border-color: rgba(91,110,232,0.6);
539 box-shadow: 0 2px 8px rgba(91,110,232,0.30);
bb2dea9Claude540 }
541 .btn-primary:hover {
6fd5915Claude542 background: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 100%);
543 box-shadow: 0 4px 14px rgba(91,110,232,0.40);
bb2dea9Claude544 text-decoration: none;
545 color: #fff;
546 }
547 .btn-secondary {
548 background: var(--bg-tertiary);
549 color: var(--text);
550 border-color: var(--border);
551 }
552 .btn-secondary:hover { background: var(--bg-hover); border-color: var(--border-strong); text-decoration: none; color: var(--text-strong); }
553 .btn-ghost {
554 background: transparent;
555 color: var(--text-muted);
556 border-color: transparent;
557 }
558 .btn-ghost:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
559`;
560
561// ─── Conversion logic ─────────────────────────────────────────────────────────
562
563/** Known GitHub Actions that map to Gluecron env vars. */
564const SETUP_ACTIONS: Record<string, { envKey: string; envValue: string; label: string }> = {
565 "actions/setup-node": {
566 envKey: "NODE_VERSION",
567 envValue: "20",
568 label: 'NODE_VERSION: "20"',
569 },
570 "actions/setup-python": {
571 envKey: "PYTHON_VERSION",
572 envValue: "3.11",
573 label: 'PYTHON_VERSION: "3.11"',
574 },
575 "actions/setup-java": {
576 envKey: "JAVA_VERSION",
577 envValue: "21",
578 label: 'JAVA_VERSION: "21"',
579 },
580 "actions/setup-go": {
581 envKey: "GO_VERSION",
582 envValue: "1.22",
583 label: 'GO_VERSION: "1.22"',
584 },
585 "actions/setup-ruby": {
586 envKey: "RUBY_VERSION",
587 envValue: "3.3",
588 label: 'RUBY_VERSION: "3.3"',
589 },
590 "actions/setup-dotnet": {
591 envKey: "DOTNET_VERSION",
592 envValue: "8.0",
593 label: 'DOTNET_VERSION: "8.0"',
594 },
595};
596
597/** Well-known run commands → gate names. */
598const RUN_NAME_MAP: Array<{ pattern: RegExp; name: string }> = [
599 { pattern: /\bnpm\s+test\b/, name: "Test" },
600 { pattern: /\bnpm\s+run\s+test\b/, name: "Test" },
601 { pattern: /\bnpm\s+run\s+lint\b/, name: "Lint" },
602 { pattern: /\bnpm\s+run\s+build\b/, name: "Build" },
603 { pattern: /\bpnpm\s+test\b/, name: "Test" },
604 { pattern: /\bpnpm\s+run\s+test\b/, name: "Test" },
605 { pattern: /\bpnpm\s+run\s+lint\b/, name: "Lint" },
606 { pattern: /\bpnpm\s+run\s+build\b/, name: "Build" },
607 { pattern: /\byarn\s+test\b/, name: "Test" },
608 { pattern: /\byarn\s+lint\b/, name: "Lint" },
609 { pattern: /\byarn\s+build\b/, name: "Build" },
610 { pattern: /\bpython\s+-m\s+pytest\b/, name: "Test" },
611 { pattern: /\bpytest\b/, name: "Test" },
612 { pattern: /\bflake8\b/, name: "Lint" },
613 { pattern: /\bruff\b/, name: "Lint" },
614 { pattern: /\bblack\b/, name: "Lint" },
615 { pattern: /\bgo\s+test\b/, name: "Test" },
616 { pattern: /\bgo\s+build\b/, name: "Build" },
617 { pattern: /\bmvn\s+test\b/, name: "Test" },
618 { pattern: /\bmvn\s+package\b/, name: "Build" },
619 { pattern: /\bgradle\b.*test\b/, name: "Test" },
620 { pattern: /\bgradle\b.*build\b/, name: "Build" },
621 { pattern: /\bcargo\s+test\b/, name: "Test" },
622 { pattern: /\bcargo\s+build\b/, name: "Build" },
623 { pattern: /\bcargo\s+clippy\b/, name: "Lint" },
624];
625
626/** Actions that are silently dropped (no gate produced). */
627const IGNORED_ACTIONS = new Set([
628 "actions/checkout",
629 "actions/upload-artifact",
630 "actions/download-artifact",
631 "actions/cache",
632 "actions/github-script",
633]);
634
635/**
636 * Very lightweight YAML line-by-line parser specifically for GitHub Actions
637 * workflow files. Does NOT need a full YAML parser — we extract jobs, steps,
638 * `uses:` and `run:` keys, and the `on:` trigger events.
639 */
640function convertActionsYaml(raw: string): ConversionResult {
641 const lines = raw.split("\n");
642
643 // ── Pass 1: collect trigger events from `on:` block ──────────────────────
644 const triggerEvents: string[] = [];
645 let inOn = false;
646 for (const line of lines) {
647 const trimmed = line.trim();
648 if (/^on\s*:/.test(trimmed)) {
649 inOn = true;
650 // Inline form: `on: [push, pull_request]`
651 const inlineMatch = trimmed.match(/^on\s*:\s*\[([^\]]+)\]/);
652 if (inlineMatch) {
653 for (const e of inlineMatch[1].split(",")) triggerEvents.push(e.trim());
654 inOn = false;
655 }
656 continue;
657 }
658 if (inOn) {
659 if (/^\S/.test(line) && !/^\s*-/.test(line) && !trimmed.startsWith("#")) {
660 // New top-level key — we've left the `on:` block
661 inOn = false;
662 continue;
663 }
664 const evtMatch = trimmed.match(/^-?\s*(\w[\w_-]*)(?:\s*:)?$/);
665 if (evtMatch) triggerEvents.push(evtMatch[1]);
666 }
667 }
668
669 const events =
670 triggerEvents.length > 0
671 ? triggerEvents.filter((e) => ["push", "pull_request", "schedule", "workflow_dispatch"].includes(e))
672 : ["push", "pull_request"];
673 const finalEvents = events.length > 0 ? events : ["push", "pull_request"];
674
675 // ── Pass 2: parse jobs → steps ────────────────────────────────────────────
676 const gates: GateStep[] = [];
677 const supported: SupportedMapping[] = [];
678 const unsupported: UnsupportedItem[] = [];
679 const seenGateNames = new Map<string, number>();
680
681 // Track per-job env vars (from setup actions)
682 let currentJobEnv: Record<string, string> = {};
683 const pendingRunSteps: Array<{ run: string; stepName: string }> = [];
684
685 let inJobs = false;
686 let inSteps = false;
687 let currentUses = "";
688 let currentRun = "";
689 let currentStepName = "";
690 let stepIndent = -1;
691 let stepsIndent = -1;
692
693 const flushRunStep = (runCmd: string, stepName: string) => {
694 if (!runCmd.trim()) return;
695 // Replace ${{ secrets.FOO }} → $FOO
696 const converted = runCmd.replace(/\$\{\{\s*secrets\.([A-Za-z0-9_]+)\s*\}\}/g, "\\$$1");
697
698 // Determine gate name
699 let gateName = stepName || "";
700 if (!gateName) {
701 for (const { pattern, name } of RUN_NAME_MAP) {
702 if (pattern.test(converted)) {
703 gateName = name;
704 break;
705 }
706 }
707 }
708 if (!gateName) gateName = "CI";
709
710 // Deduplicate gate names
711 const existing = seenGateNames.get(gateName) ?? 0;
712 seenGateNames.set(gateName, existing + 1);
713 const uniqueName = existing === 0 ? gateName : `${gateName} ${existing + 1}`;
714
715 const gate: GateStep = {
716 name: uniqueName,
717 run: converted.trim(),
718 on: finalEvents,
719 };
720 if (Object.keys(currentJobEnv).length > 0) {
721 gate.env = { ...currentJobEnv };
722 }
723 gates.push(gate);
724
725 // Track supported mapping for secrets
726 if (runCmd.includes("${{")) {
727 const secretMatches = runCmd.matchAll(/\$\{\{\s*secrets\.([A-Za-z0-9_]+)\s*\}\}/g);
728 for (const m of secretMatches) {
729 supported.push({ from: `\${{ secrets.${m[1]} }}`, to: `\$${m[1]}` });
730 }
731 }
732 };
733
734 const flushUses = (uses: string) => {
735 const action = uses.split("@")[0];
736 if (IGNORED_ACTIONS.has(action)) {
737 if (action === "actions/checkout") {
738 supported.push({
739 from: uses,
740 to: "Automatic (Gluecron clones before every gate run)",
741 });
742 }
743 return;
744 }
745 if (SETUP_ACTIONS[action]) {
746 const mapping = SETUP_ACTIONS[action];
747 currentJobEnv[mapping.envKey] = mapping.envValue;
748 supported.push({ from: uses, to: mapping.label });
749 return;
750 }
751 // Unknown action — surface as unsupported
752 unsupported.push({
753 action: uses,
754 reason: "Third-party action — check the action's README and replicate the equivalent shell commands.",
755 });
756 };
757
758 for (let i = 0; i < lines.length; i++) {
759 const line = lines[i];
760 const trimmed = line.trim();
761 if (!trimmed || trimmed.startsWith("#")) continue;
762
763 const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
764
765 // Top-level `jobs:` marker
766 if (/^jobs\s*:/.test(trimmed) && indent === 0) {
767 inJobs = true;
768 inSteps = false;
769 continue;
770 }
771
772 if (!inJobs) continue;
773
774 // Job-level key (2-space or 4-space indent) — new job resets env + steps
775 if (indent <= 2 && /^[a-z_-]+\s*:/i.test(trimmed) && trimmed !== "steps:") {
776 // Flush any pending run from previous job
777 if (currentRun) {
778 flushRunStep(currentRun, currentStepName);
779 currentRun = "";
780 currentStepName = "";
781 }
782 currentJobEnv = {};
783 inSteps = false;
784 stepsIndent = -1;
785 stepIndent = -1;
786 continue;
787 }
788
789 // `steps:` block
790 if (/^steps\s*:/.test(trimmed)) {
791 inSteps = true;
792 stepsIndent = indent;
793 if (currentRun) {
794 flushRunStep(currentRun, currentStepName);
795 currentRun = "";
796 currentStepName = "";
797 }
798 continue;
799 }
800
801 if (!inSteps) continue;
802
803 // New step marker (list item `- `)
804 if (trimmed.startsWith("- ") || trimmed === "-") {
805 // Flush previous step
806 if (currentUses) {
807 flushUses(currentUses);
808 currentUses = "";
809 }
810 if (currentRun) {
811 flushRunStep(currentRun, currentStepName);
812 currentRun = "";
813 }
814 currentStepName = "";
815 stepIndent = indent;
816
817 const restOfLine = trimmed.slice(2).trim();
818 if (restOfLine.startsWith("name:")) {
819 currentStepName = restOfLine.replace(/^name\s*:\s*/, "").replace(/^['"]|['"]$/g, "");
820 } else if (restOfLine.startsWith("uses:")) {
821 currentUses = restOfLine.replace(/^uses\s*:\s*/, "").trim();
822 } else if (restOfLine.startsWith("run:")) {
823 currentRun = restOfLine.replace(/^run\s*:\s*/, "");
824 }
825 continue;
826 }
827
828 // Continuation lines within a step
829 if (stepIndent >= 0 && indent > stepIndent) {
830 if (/^name\s*:/i.test(trimmed)) {
831 currentStepName = trimmed.replace(/^name\s*:\s*/i, "").replace(/^['"]|['"]$/g, "");
832 } else if (/^uses\s*:/i.test(trimmed)) {
833 currentUses = trimmed.replace(/^uses\s*:\s*/i, "").trim();
834 } else if (/^run\s*:/i.test(trimmed)) {
835 currentRun = trimmed.replace(/^run\s*:\s*/i, "");
836 } else if (currentRun && /^\|/.test(trimmed)) {
837 // multiline run block marker — the run content is on following lines
838 currentRun = "";
839 } else if (currentRun !== "" && trimmed && !trimmed.includes(":")) {
840 // Continuation of multiline run
841 currentRun += "\n" + trimmed;
842 } else if (currentRun && /^\$\{\{/.test(trimmed) || (currentRun && trimmed.match(/^[a-z]/))) {
843 // looks like a run continuation
844 currentRun += " " + trimmed;
845 }
846 }
847 }
848
849 // Flush last step
850 if (currentUses) flushUses(currentUses);
851 if (currentRun) flushRunStep(currentRun, currentStepName);
852
853 // ── Check for unsupported github.* expressions ────────────────────────────
854 const githubExprCount = (raw.match(/\$\{\{\s*github\./g) || []).length;
855 if (githubExprCount > 0) {
856 unsupported.push({
857 action: "${{ github.* }} expressions (" + githubExprCount + " found)",
858 reason: "GitHub context variables have no direct Gluecron equivalent. Use environment variables or gate output for dynamic values.",
859 });
860 }
861
862 // ── Check for env.* expressions ──────────────────────────────────────────
863 const envExprCount = (raw.match(/\$\{\{\s*env\./g) || []).length;
864 if (envExprCount > 0) {
865 unsupported.push({
866 action: "${{ env.* }} expressions (" + envExprCount + " found)",
867 reason: "Gluecron uses plain shell environment variables. Replace with the equivalent $VAR_NAME syntax.",
868 });
869 }
870
871 // ── Dedup supported mappings ──────────────────────────────────────────────
872 const seenFrom = new Set<string>();
873 const dedupedSupported = supported.filter((s) => {
874 if (seenFrom.has(s.from)) return false;
875 seenFrom.add(s.from);
876 return true;
877 });
878
879 // ── Serialize to gates.yml ────────────────────────────────────────────────
880 let gatesYaml = "# .gluecron/gates.yml\n# Generated by Gluecron GitHub Actions Importer\n\n";
881 if (gates.length === 0) {
882 gatesYaml += "gates: []\n\n# No convertible run steps were found.\n# Paste a workflow with `run:` steps to generate gates.\n";
883 } else {
884 gatesYaml += "gates:\n";
885 for (const gate of gates) {
886 gatesYaml += ` - name: "${gate.name}"\n`;
887 gatesYaml += ` on: [${gate.on.join(", ")}]\n`;
888 if (gate.env && Object.keys(gate.env).length > 0) {
889 gatesYaml += " env:\n";
890 for (const [k, v] of Object.entries(gate.env)) {
891 gatesYaml += ` ${k}: "${v}"\n`;
892 }
893 }
894 // Multi-line run uses YAML block scalar
895 if (gate.run.includes("\n")) {
896 gatesYaml += " run: |\n";
897 for (const runLine of gate.run.split("\n")) {
898 gatesYaml += ` ${runLine}\n`;
899 }
900 } else {
901 gatesYaml += ` run: "${gate.run.replace(/"/g, '\\"')}"\n`;
902 }
903 }
904 }
905
906 return { gates, supported: dedupedSupported, unsupported, gatesYaml };
907}
908
909// ─── Routes ──────────────────────────────────────────────────────────────────
910
911// GET /import/actions — landing form
912actionsImporterRoutes.get("/import/actions", (c) => {
913 const user = c.get("user");
914 return c.html(
915 <Layout
916 title="GitHub Actions Importer — gluecron"
917 user={user}
918 description="Convert your GitHub Actions workflows to Gluecron gates.yml in seconds. Paste YAML or upload a workflow file."
919 >
920 <style dangerouslySetInnerHTML={{ __html: importerStyles }} />
921 <div class="ai-wrap">
922 <ImporterHero />
923 <ImporterForm />
924 <AutoImportNote />
925 </div>
926 </Layout>,
927 );
928});
929
930// POST /import/actions — convert and show results
931actionsImporterRoutes.post("/import/actions", async (c) => {
932 const user = c.get("user");
933
934 const formData = await c.req.parseBody();
935 let rawYaml = (formData["yaml"] as string | undefined) ?? "";
936
937 // File upload support — browser sends as Blob
938 if (!rawYaml && formData["file"]) {
939 const file = formData["file"] as File | undefined;
940 if (file && typeof file.text === "function") {
941 rawYaml = await file.text();
942 }
943 }
944
945 rawYaml = rawYaml.trim();
946
947 if (!rawYaml) {
948 return c.html(
949 <Layout title="GitHub Actions Importer — gluecron" user={user}>
950 <style dangerouslySetInnerHTML={{ __html: importerStyles }} />
951 <div class="ai-wrap">
952 <ImporterHero />
953 <div
954 style="padding:16px 20px;border-radius:10px;background:rgba(248,113,113,0.1);border:1px solid rgba(248,113,113,0.3);color:var(--red);font-size:14px;margin-bottom:24px"
955 >
956 No YAML provided. Please paste a workflow or upload a file.
957 </div>
958 <ImporterForm />
959 </div>
960 </Layout>,
961 400,
962 );
963 }
964
965 const result = convertActionsYaml(rawYaml);
966
967 return c.html(
968 <Layout title="Converted — GitHub Actions Importer" user={user}>
969 <style dangerouslySetInnerHTML={{ __html: importerStyles }} />
970 <div class="ai-wrap">
971 <ImporterHero compact />
972 <div class="ai-try-again">
973 <a href="/import/actions" class="btn btn-secondary">
974 ← Try another workflow
975 </a>
976 <a href="/import/actions/guide" class="btn btn-ghost">
977 Migration guide
978 </a>
979 </div>
980 <AutoImportNote />
981 <ResultsSplitPanel original={rawYaml} result={result} />
982 <ResultsMappings result={result} />
983 </div>
984 </Layout>,
985 );
986});
987
988// GET /import/actions/guide — migration guide
989actionsImporterRoutes.get("/import/actions/guide", (c) => {
990 const user = c.get("user");
991 return c.html(
992 <Layout
993 title="GitHub Actions → Gluecron Migration Guide"
994 user={user}
995 description="Step-by-step guide for migrating your GitHub Actions CI/CD pipelines to Gluecron gates."
996 >
997 <style dangerouslySetInnerHTML={{ __html: importerStyles }} />
998 <div class="ai-guide-wrap">
999 <MigrationGuide />
1000 </div>
1001 </Layout>,
1002 );
1003});
1004
1005// ─── JSX Components ──────────────────────────────────────────────────────────
1006
1007function ImporterHero({ compact }: { compact?: boolean }) {
1008 if (compact) {
1009 return (
1010 <div style="margin-bottom:var(--space-5)">
1011 <div class="ai-eyebrow">GitHub Actions Importer</div>
1012 <h1 style="font-size:24px;font-family:var(--font-display);font-weight:700;color:var(--text-strong);margin:0">
1013 Conversion results
1014 </h1>
1015 </div>
1016 );
1017 }
1018 return (
1019 <div class="ai-hero">
1020 <div class="ai-hero-orb" aria-hidden="true" />
1021 <div class="ai-hero-orb2" aria-hidden="true" />
1022 <div class="ai-hero-content">
1023 <div class="ai-eyebrow">Migration tools</div>
1024 <h1>
1025 Convert GitHub Actions to{" "}
1026 <span class="ai-grad">Gluecron gates</span>
1027 </h1>
1028 <p class="ai-hero-sub">
1029 Paste your <code>.github/workflows/*.yml</code> file and get an
1030 equivalent <code>.gluecron/gates.yml</code> in seconds. The #1
1031 migration blocker — eliminated.
1032 </p>
1033 <div style="display:flex;align-items:center;justify-content:center;gap:var(--space-3);flex-wrap:wrap">
1034 <a href="#converter-form" class="btn btn-primary">
1035 Start converting
1036 </a>
1037 <a href="/import/actions/guide" class="btn btn-secondary">
1038 Migration guide
1039 </a>
1040 </div>
1041 </div>
1042 </div>
1043 );
1044}
1045
1046function AutoImportNote() {
1047 return (
1048 <div class="ai-auto-note">
1049 <div class="ai-auto-note-icon" aria-hidden="true">
1050 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
1051 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1052 <polyline points="17 8 12 3 7 8" />
1053 <line x1="12" y1="3" x2="12" y2="15" />
1054 </svg>
1055 </div>
1056 <div class="ai-auto-note-text">
1057 <strong>Import a repo from GitHub and this conversion runs automatically.</strong>{" "}
1058 When you use{" "}
1059 <a href="/import">Import from GitHub</a>, Gluecron detects your
1060 workflow files and generates a <code>.gluecron/gates.yml</code> for
1061 you — no manual copy-paste required.
1062 </div>
1063 </div>
1064 );
1065}
1066
1067function ImporterForm() {
1068 return (
1069 <div class="ai-form-card" id="converter-form">
b271465Claude1070 <form method="post" action="/import/actions" enctype="multipart/form-data">
bb2dea9Claude1071 <label class="ai-form-label" for="yaml-input">
1072 Paste your GitHub Actions workflow YAML
1073 </label>
1074 <p class="ai-form-hint">
1075 Paste the contents of a <code>.github/workflows/*.yml</code> file
1076 below. Secrets, setup steps, and run commands are all handled.
1077 </p>
1078 <textarea
1079 id="yaml-input"
1080 name="yaml"
1081 class="ai-textarea"
1082 placeholder={`name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n - run: npm ci\n - run: npm test\n - run: npm run lint`}
1083 spellcheck={false}
1084 autocomplete="off"
1085 />
1086
1087 <div class="ai-or-sep">or upload a file</div>
1088
1089 <label class="ai-upload-zone" for="file-upload" tabIndex={0}>
1090 <svg
1091 width="22"
1092 height="22"
1093 viewBox="0 0 24 24"
1094 fill="none"
1095 stroke="currentColor"
1096 stroke-width="1.8"
1097 stroke-linecap="round"
1098 stroke-linejoin="round"
1099 style="margin-bottom:8px;color:var(--text-faint)"
1100 >
1101 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1102 <polyline points="17 8 12 3 7 8" />
1103 <line x1="12" y1="3" x2="12" y2="15" />
1104 </svg>
1105 <div>
1106 Drop a <code>.yml</code> workflow file here, or{" "}
1107 <span style="color:var(--accent);text-decoration:underline">browse</span>
1108 </div>
1109 <div style="font-size:11px;color:var(--text-faint);margin-top:4px">
1110 YAML files only &mdash; max 512 KB
1111 </div>
1112 <input
1113 id="file-upload"
1114 type="file"
1115 name="file"
1116 accept=".yml,.yaml,text/yaml,application/yaml"
1117 />
1118 </label>
1119
1120 <div class="ai-submit-row">
1121 <button type="submit" class="btn btn-primary">
1122 Convert to Gluecron gates.yml
1123 </button>
1124 <span class="ai-note">
1125 No data is stored. The conversion runs entirely on the server and
1126 the result is returned immediately.{" "}
1127 <a href="/import/actions/guide">Read the migration guide →</a>
1128 </span>
1129 </div>
1130 </form>
1131 </div>
1132 );
1133}
1134
1135function ResultsSplitPanel({ original, result }: { original: string; result: ConversionResult }) {
1136 const outputId = "ai-gates-output";
1137 const inputId = "ai-original-input";
1138 const gateCount = result.gates.length;
1139
1140 return (
1141 <div class="ai-split">
1142 {/* Left: original */}
1143 <div class="ai-panel">
1144 <div class="ai-panel-header">
1145 <span class="ai-panel-title">.github/workflows/ci.yml</span>
1146 <span class="ai-panel-badge">Original</span>
1147 </div>
1148 <pre class="ai-code-block" id={inputId}>{original}</pre>
1149 </div>
1150
1151 {/* Right: converted */}
1152 <div class="ai-panel">
1153 <div class="ai-panel-header">
1154 <span class="ai-panel-title">.gluecron/gates.yml</span>
1155 <span class={`ai-panel-badge${gateCount > 0 ? " badge-green" : ""}`}>
1156 {gateCount} {gateCount === 1 ? "gate" : "gates"} converted
1157 </span>
1158 </div>
1159 <pre class="ai-code-block" id={outputId}>{result.gatesYaml}</pre>
1160 <div class="ai-panel-actions">
1161 <button
1162 type="button"
1163 class="btn btn-secondary"
1164 onclick={`(function(){
1165 var el = document.getElementById('${outputId}');
1166 if (!el) return;
1167 navigator.clipboard && navigator.clipboard.writeText(el.textContent || '').then(function(){
1168 var b = event.currentTarget;
1169 var orig = b.textContent;
1170 b.textContent = 'Copied!';
1171 setTimeout(function(){ b.textContent = orig; }, 1800);
1172 });
1173 })()`}
1174 >
1175 Copy to clipboard
1176 </button>
1177 <button
1178 type="button"
1179 class="btn btn-ghost"
1180 onclick={`(function(){
1181 var el = document.getElementById('${outputId}');
1182 if (!el) return;
1183 var blob = new Blob([el.textContent || ''], { type: 'text/yaml' });
1184 var url = URL.createObjectURL(blob);
1185 var a = document.createElement('a');
1186 a.href = url;
1187 a.download = 'gates.yml';
1188 document.body.appendChild(a);
1189 a.click();
1190 setTimeout(function(){ document.body.removeChild(a); URL.revokeObjectURL(url); }, 500);
1191 })()`}
1192 >
1193 Download as .gluecron/gates.yml
1194 </button>
1195 </div>
1196 </div>
1197 </div>
1198 );
1199}
1200
1201function ResultsMappings({ result }: { result: ConversionResult }) {
1202 return (
1203 <div class="ai-results-grid">
1204 {/* Supported mappings */}
1205 <div class="ai-card">
1206 <div class="ai-card-head">
1207 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
1208 <polyline points="20 6 9 17 4 12" />
1209 </svg>
1210 Supported mappings ({result.supported.length})
1211 </div>
1212 <div class="ai-card-body">
1213 {result.supported.length === 0 ? (
1214 <div class="ai-empty">No mapped actions detected in this workflow.</div>
1215 ) : (
1216 result.supported.map((m) => (
1217 <div class="ai-map-row">
1218 <span class="ai-map-icon ok" aria-label="Supported">✓</span>
1219 <div class="ai-map-content">
1220 <div class="ai-map-from">{m.from}</div>
1221 <div style="display:flex;align-items:center;gap:6px;margin-top:2px">
1222 <span class="ai-map-arrow">→</span>
1223 <div class="ai-map-to">{m.to}</div>
1224 </div>
1225 </div>
1226 </div>
1227 ))
1228 )}
1229 </div>
1230 </div>
1231
1232 {/* Unsupported / manual attention */}
1233 <div class="ai-card">
1234 <div class="ai-card-head">
1235 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--yellow)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
1236 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
1237 <line x1="12" y1="9" x2="12" y2="13" />
1238 <line x1="12" y1="17" x2="12.01" y2="17" />
1239 </svg>
1240 Needs manual attention ({result.unsupported.length})
1241 </div>
1242 <div class="ai-card-body">
1243 {result.unsupported.length === 0 ? (
1244 <div class="ai-empty" style="color:var(--green)">
1245 All actions were handled automatically.
1246 </div>
1247 ) : (
1248 result.unsupported.map((u) => (
1249 <div class="ai-map-row">
1250 <span class="ai-map-icon warn" aria-label="Needs attention">!</span>
1251 <div class="ai-map-content">
1252 <div class="ai-map-from">{u.action}</div>
1253 <div class="ai-map-reason">{u.reason}</div>
1254 </div>
1255 </div>
1256 ))
1257 )}
1258 </div>
1259 </div>
1260 </div>
1261 );
1262}
1263
1264function MigrationGuide() {
1265 return (
1266 <>
1267 <div class="ai-eyebrow" style="margin-bottom:var(--space-3)">Migration guide</div>
1268 <h1 style="font-size:clamp(28px,4vw,40px);font-family:var(--font-display);font-weight:700;letter-spacing:-0.03em;line-height:1.1;margin-bottom:var(--space-3);color:var(--text-strong)">
1269 GitHub Actions → Gluecron gates
1270 </h1>
1271 <p style="font-size:16px;color:var(--text-muted);margin-bottom:var(--space-8);max-width:620px">
1272 Everything you need to know to migrate your CI/CD pipelines from
1273 GitHub Actions to Gluecron&rsquo;s gate system — with zero downtime and
1274 full automation coverage.
1275 </p>
1276
1277 <h2>Overview</h2>
1278 <p>
1279 Gluecron replaces GitHub Actions workflows with a lightweight{" "}
1280 <code>.gluecron/gates.yml</code> file checked into your repository.
1281 Gates run on push and pull request events — just like GitHub Actions —
1282 but without the YAML boilerplate, the runner billing, or the
1283 marketplace dependency risk.
1284 </p>
1285 <p>
1286 Key differences from GitHub Actions:
1287 </p>
1288 <ul>
1289 <li>
1290 <strong>No checkout step needed.</strong> Gluecron clones your repo
1291 automatically before every gate run.
1292 </li>
1293 <li>
1294 <strong>No runner OS selection.</strong> All gates run in a managed,
1295 clean environment per push.
1296 </li>
1297 <li>
1298 <strong>Secrets are plain env vars.</strong>{" "}
1299 <code>{"${{ secrets.MY_TOKEN }}"}</code> becomes{" "}
1300 <code>$MY_TOKEN</code> — set in your repo&rsquo;s Secrets settings.
1301 </li>
1302 <li>
1303 <strong>No matrix builds yet.</strong> Run multiple gates instead of
1304 a matrix strategy.
1305 </li>
1306 </ul>
1307
1308 <h2>Quick start</h2>
1309 <ol class="ai-step-list">
1310 <li class="ai-step-item">
1311 <div class="ai-step-num">1</div>
1312 <div class="ai-step-body">
1313 <div class="ai-step-title">Paste your workflow into the importer</div>
1314 <div class="ai-step-desc">
1315 Head to{" "}
1316 <a href="/import/actions">/import/actions</a> and paste your{" "}
1317 <code>.github/workflows/*.yml</code> file. The converter handles
1318 secrets, setup actions, and run commands automatically.
1319 </div>
1320 </div>
1321 </li>
1322 <li class="ai-step-item">
1323 <div class="ai-step-num">2</div>
1324 <div class="ai-step-body">
1325 <div class="ai-step-title">Download the generated gates.yml</div>
1326 <div class="ai-step-desc">
1327 Click &ldquo;Download as .gluecron/gates.yml&rdquo; and commit
1328 the file to the root of your repo at{" "}
1329 <code>.gluecron/gates.yml</code>.
1330 </div>
1331 </div>
1332 </li>
1333 <li class="ai-step-item">
1334 <div class="ai-step-num">3</div>
1335 <div class="ai-step-body">
1336 <div class="ai-step-title">Migrate your secrets</div>
1337 <div class="ai-step-desc">
1338 Go to your repo&rsquo;s <strong>Settings → Secrets</strong> and
1339 add each secret that your workflow referenced. The names stay the
1340 same — only the reference syntax changes.
1341 </div>
1342 </div>
1343 </li>
1344 <li class="ai-step-item">
1345 <div class="ai-step-num">4</div>
1346 <div class="ai-step-body">
1347 <div class="ai-step-title">Push and verify</div>
1348 <div class="ai-step-desc">
1349 Push a commit and watch your gates run in real time under{" "}
1350 <strong>/:owner/:repo/push/:sha</strong>. Green gates on the
1351 first push means you&rsquo;re done.
1352 </div>
1353 </div>
1354 </li>
1355 </ol>
1356
1357 <h2>Gates YAML reference</h2>
1358 <p>
1359 A complete <code>.gluecron/gates.yml</code> looks like this:
1360 </p>
1361 <pre class="ai-guide-pre">{`gates:
1362 - name: "Test"
1363 on: [push, pull_request]
1364 env:
1365 NODE_VERSION: "20"
1366 run: "npm test"
1367
1368 - name: "Lint"
1369 on: [push, pull_request]
1370 run: "npm run lint"
1371
1372 - name: "Build"
1373 on: [push]
1374 run: "npm run build"`}
1375 </pre>
1376
1377 <h2>Action mapping reference</h2>
1378 <p>
1379 The following GitHub Actions are automatically converted:
1380 </p>
1381
1382 <div style="overflow-x:auto;margin-bottom:var(--space-6)">
1383 <table style="width:100%;border-collapse:collapse;font-size:13.5px">
1384 <thead>
1385 <tr style="border-bottom:1px solid var(--border)">
1386 <th style="text-align:left;padding:10px 12px;color:var(--text-muted);font-weight:600">GitHub Actions</th>
1387 <th style="text-align:left;padding:10px 12px;color:var(--text-muted);font-weight:600">Gluecron equivalent</th>
1388 </tr>
1389 </thead>
1390 <tbody>
1391 {[
1392 ["actions/checkout@v3", "Automatic — no step needed"],
1393 ["actions/setup-node@v3 (node-version: 20)", 'NODE_VERSION: "20" env var'],
1394 ["actions/setup-python@v3 (python-version: 3.11)", 'PYTHON_VERSION: "3.11" env var'],
1395 ["actions/setup-java@v3", 'JAVA_VERSION: "21" env var'],
1396 ["actions/setup-go@v3", 'GO_VERSION: "1.22" env var'],
1397 ["actions/setup-ruby@v3", 'RUBY_VERSION: "3.3" env var'],
1398 ["actions/setup-dotnet@v3", 'DOTNET_VERSION: "8.0" env var'],
1399 ["actions/cache@v3", "No equivalent — cache is persistent across runs"],
1400 ["actions/upload-artifact@v3", "No equivalent — use external storage or release assets"],
1401 ['${{ secrets.MY_TOKEN }}', "$MY_TOKEN (plain env var)"],
1402 ].map(([from, to]) => (
1403 <tr style="border-bottom:1px solid var(--border-subtle)">
1404 <td style="padding:10px 12px;font-family:var(--font-mono);font-size:12.5px;color:var(--text-muted)">{from}</td>
1405 <td style="padding:10px 12px;font-family:var(--font-mono);font-size:12.5px;color:var(--text-strong)">{to}</td>
1406 </tr>
1407 ))}
1408 </tbody>
1409 </table>
1410 </div>
1411
1412 <h2>Manual steps</h2>
1413 <p>
1414 Some GitHub Actions patterns need manual adaptation:
1415 </p>
1416 <ul>
1417 <li>
1418 <strong>Matrix builds</strong> — create separate named gates instead.
1419 E.g. replace a <code>node: [18, 20, 22]</code> matrix with three
1420 gates named &ldquo;Test Node 18&rdquo;, &ldquo;Test Node 20&rdquo;,
1421 &ldquo;Test Node 22&rdquo;.
1422 </li>
1423 <li>
1424 <strong>Workflow-level env / outputs</strong> — use plain shell
1425 variables or a shared setup script sourced by multiple gates.
1426 </li>
1427 <li>
1428 <strong>if: conditions</strong> — Gluecron gates run on every
1429 matched event. Conditional logic should live inside the run script.
1430 </li>
1431 <li>
1432 <strong>Third-party marketplace actions</strong> — read the action
1433 README and replicate the equivalent shell commands in a{" "}
1434 <code>run:</code> step.
1435 </li>
1436 <li>
1437 <strong>Scheduled workflows (<code>schedule:</code>)</strong> — use
1438 Gluecron&rsquo;s cron gate trigger once available, or migrate to a
1439 lightweight external scheduler.
1440 </li>
1441 </ul>
1442
1443 <h2>Need help?</h2>
1444 <p>
1445 Open an issue on any of your repos or reach us at{" "}
1446 <a href="/help">the help page</a>. The{" "}
1447 <a href="/import/actions">Actions Importer</a> handles the common 80%
1448 — for complex workflows, the Gluecron team can review your YAML and
1449 provide a hand-crafted gates.yml.
1450 </p>
1451
1452 <div style="margin-top:var(--space-8);display:flex;gap:var(--space-3);flex-wrap:wrap">
1453 <a href="/import/actions" class="btn btn-primary">
1454 Open the importer
1455 </a>
1456 <a href="/import" class="btn btn-secondary">
1457 Import a repo from GitHub
1458 </a>
1459 </div>
1460 </>
1461 );
1462}
1463
1464export default actionsImporterRoutes;