pipecraft 0.28.2 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +5 -2
  2. package/dist/cli/index.js +1 -9
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/generators/init.tpl.d.ts.map +1 -1
  5. package/dist/generators/init.tpl.js +63 -12
  6. package/dist/generators/init.tpl.js.map +1 -1
  7. package/dist/generators/workflows.tpl.d.ts.map +1 -1
  8. package/dist/generators/workflows.tpl.js +10 -3
  9. package/dist/generators/workflows.tpl.js.map +1 -1
  10. package/dist/templates/workflows/pipeline-nx.yml.tpl.d.ts +22 -0
  11. package/dist/templates/workflows/pipeline-nx.yml.tpl.d.ts.map +1 -0
  12. package/dist/templates/workflows/pipeline-nx.yml.tpl.js +179 -0
  13. package/dist/templates/workflows/pipeline-nx.yml.tpl.js.map +1 -0
  14. package/dist/templates/workflows/pipeline.yml.tpl.d.ts +22 -0
  15. package/dist/templates/workflows/pipeline.yml.tpl.d.ts.map +1 -0
  16. package/dist/templates/workflows/pipeline.yml.tpl.js +136 -0
  17. package/dist/templates/workflows/pipeline.yml.tpl.js.map +1 -0
  18. package/dist/templates/workflows/shared/index.d.ts +11 -0
  19. package/dist/templates/workflows/shared/index.d.ts.map +1 -0
  20. package/dist/templates/workflows/shared/index.js +11 -0
  21. package/dist/templates/workflows/shared/index.js.map +1 -0
  22. package/dist/templates/workflows/shared/operations-changes.d.ts +17 -0
  23. package/dist/templates/workflows/shared/operations-changes.d.ts.map +1 -0
  24. package/dist/templates/workflows/shared/operations-changes.js +49 -0
  25. package/dist/templates/workflows/shared/operations-changes.js.map +1 -0
  26. package/dist/templates/workflows/shared/operations-domain-jobs.d.ts +31 -0
  27. package/dist/templates/workflows/shared/operations-domain-jobs.d.ts.map +1 -0
  28. package/dist/templates/workflows/shared/operations-domain-jobs.js +139 -0
  29. package/dist/templates/workflows/shared/operations-domain-jobs.js.map +1 -0
  30. package/dist/templates/workflows/shared/operations-header.d.ts +15 -0
  31. package/dist/templates/workflows/shared/operations-header.d.ts.map +1 -0
  32. package/dist/templates/workflows/shared/operations-header.js +158 -0
  33. package/dist/templates/workflows/shared/operations-header.js.map +1 -0
  34. package/dist/templates/workflows/shared/operations-tag-promote.d.ts +16 -0
  35. package/dist/templates/workflows/shared/operations-tag-promote.d.ts.map +1 -0
  36. package/dist/templates/workflows/shared/operations-tag-promote.js +115 -0
  37. package/dist/templates/workflows/shared/operations-tag-promote.js.map +1 -0
  38. package/dist/templates/workflows/shared/operations-version.d.ts +16 -0
  39. package/dist/templates/workflows/shared/operations-version.d.ts.map +1 -0
  40. package/dist/templates/workflows/shared/operations-version.js +57 -0
  41. package/dist/templates/workflows/shared/operations-version.js.map +1 -0
  42. package/dist/templates/yaml-format-utils.d.ts +24 -0
  43. package/dist/templates/yaml-format-utils.d.ts.map +1 -0
  44. package/dist/templates/yaml-format-utils.js +50 -0
  45. package/dist/templates/yaml-format-utils.js.map +1 -0
  46. package/dist/types/index.d.ts +42 -0
  47. package/dist/types/index.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/generators/init.tpl.ts +71 -15
  50. package/src/generators/workflows.tpl.ts +9 -3
  51. package/dist/templates/workflows/pipeline-path-based.yml.tpl.d.ts +0 -129
  52. package/dist/templates/workflows/pipeline-path-based.yml.tpl.d.ts.map +0 -1
  53. package/dist/templates/workflows/pipeline-path-based.yml.tpl.js +0 -1057
  54. package/dist/templates/workflows/pipeline-path-based.yml.tpl.js.map +0 -1
  55. package/dist/utils/github-setup-v2.d.ts +0 -15
  56. package/dist/utils/github-setup-v2.d.ts.map +0 -1
  57. package/dist/utils/github-setup-v2.js +0 -217
  58. package/dist/utils/github-setup-v2.js.map +0 -1
@@ -1,1057 +0,0 @@
1
- /**
2
- * Path-Based Pipeline Template Generator
3
- *
4
- * The core template that generates the main CI/CD pipeline workflow for PipeCraft.
5
- * This is the most complex template in the system, responsible for creating a
6
- * GitHub Actions workflow that orchestrates the entire trunk-based development flow.
7
- *
8
- * ## Key Responsibilities
9
- *
10
- * 1. **Change Detection**: Generates jobs to detect which domains (api, web, libs, etc.) changed
11
- * 2. **Test Execution**: Creates domain-specific test jobs based on changes
12
- * 3. **Version Management**: Integrates semantic versioning for staging/production branches
13
- * 4. **Branch Promotion**: Auto-promotes code through branch flow (develop → staging → main)
14
- * 5. **User Job Preservation**: Maintains user-added custom jobs during regeneration
15
- * 6. **Comment Preservation**: Retains user comments when updating workflows
16
- *
17
- * ## Intelligent Merging
18
- *
19
- * The generator distinguishes between:
20
- * - **Pipecraft-owned jobs**: `changes`, `version`, `tag`, `promote`, `release`, `test-*`, `deploy-*`
21
- * - **User jobs**: Any jobs not owned by Pipecraft
22
- *
23
- * During regeneration:
24
- * - Pipecraft jobs are completely replaced with template versions
25
- * - User jobs are preserved exactly as-is
26
- * - User comments are maintained
27
- * - Job order is intelligently managed (Pipecraft jobs first, then user jobs)
28
- *
29
- * ## Architecture
30
- *
31
- * Uses AST-based path operations for surgical YAML manipulation:
32
- * - Parse existing workflow into AST
33
- * - Apply precise path-based operations
34
- * - Preserve formatting and comments
35
- * - Rebuild YAML maintaining structure
36
- *
37
- * @module templates/workflows/pipeline-path-based.yml.tpl
38
- *
39
- * @example
40
- * ```typescript
41
- * import { generate } from './templates/workflows/pipeline-path-based.yml.tpl.js'
42
- *
43
- * // Initial generation
44
- * await generate({
45
- * cwd: '/path/to/project',
46
- * branchFlow: ['develop', 'staging', 'main'],
47
- * domains: {
48
- * api: { paths: ['src/api/**'], test: true },
49
- * web: { paths: ['src/web/**'], test: true }
50
- * }
51
- * })
52
- *
53
- * // Incremental update (preserves user jobs)
54
- * await generate({
55
- * cwd: '/path/to/project',
56
- * existingPipeline: parsedYAML,
57
- * existingPipelineContent: rawYAMLString,
58
- * branchFlow: ['develop', 'staging', 'main'],
59
- * domains: { ... }
60
- * })
61
- * ```
62
- *
63
- * @see {@link module:utils/ast-path-operations} for YAML manipulation details
64
- */
65
- import { toFile, renderTemplate } from '@featherscloud/pinion';
66
- import { parseDocument, stringify, Scalar } from 'yaml';
67
- import fs from 'fs';
68
- import { applyPathOperations, createValueFromString } from '../../utils/ast-path-operations.js';
69
- import dedent from 'dedent';
70
- import { logger } from '../../utils/logger.js';
71
- /**
72
- * Get minimal base template - just enough structure to be parsed
73
- * All actual content is defined via operations list
74
- */
75
- const getBaseTemplate = (ctx) => {
76
- return dedent `
77
- name: "Pipeline"
78
- on:
79
- jobs:
80
- `;
81
- };
82
- /**
83
- * Define which jobs Pipecraft owns vs user jobs
84
- */
85
- const getPipecraftOwnedJobs = (branchFlow, domains = {}) => {
86
- const jobs = new Set([
87
- 'changes',
88
- 'version',
89
- 'tag',
90
- 'promote', // Promotion job - triggers workflow on next branch
91
- 'release' // GitHub release creation on final branch
92
- ]);
93
- // Add domain-based jobs (test-*, deploy-*, remote-test-*) based on flags
94
- Object.keys(domains).forEach(domain => {
95
- const domainConfig = domains[domain];
96
- if (domainConfig.testable !== false)
97
- jobs.add(`test-${domain}`);
98
- if (domainConfig.deployable === true)
99
- jobs.add(`deploy-${domain}`);
100
- if (domainConfig.remoteTestable === true)
101
- jobs.add(`remote-test-${domain}`);
102
- });
103
- return jobs;
104
- };
105
- /**
106
- * Check if a job is owned by Pipecraft
107
- */
108
- const isPipecraftJob = (jobName, branchFlow) => {
109
- return getPipecraftOwnedJobs(branchFlow).has(jobName);
110
- };
111
- /**
112
- * Create path-based pipeline content
113
- */
114
- export const createPathBasedPipeline = (ctx) => {
115
- const branchFlow = ctx.branchFlow || ['develop', 'staging', 'main'];
116
- logger.debug('🔍 Branch flow from context:', branchFlow);
117
- logger.debug('🔍 Context keys:', Object.keys(ctx));
118
- // Use existing pipeline from context or start with base template
119
- let doc;
120
- let hasExistingPipeline = false;
121
- if (ctx.existingPipelineContent) {
122
- // Parse the original YAML content to preserve structure and comments
123
- doc = parseDocument(ctx.existingPipelineContent);
124
- // Note: We save document-level comments later and selectively restore them
125
- // Don't clear doc.commentBefore here - we'll handle it after saving
126
- hasExistingPipeline = true;
127
- logger.verbose('🔄 Merging with existing pipeline');
128
- }
129
- else if (ctx.existingPipeline) {
130
- // Convert existing pipeline object to YAML string first
131
- const existingYaml = stringify(ctx.existingPipeline);
132
- doc = parseDocument(existingYaml);
133
- hasExistingPipeline = true;
134
- logger.verbose('🔄 Merging with existing pipeline');
135
- }
136
- else {
137
- doc = parseDocument(getBaseTemplate(ctx));
138
- logger.verbose('📝 Creating new pipeline');
139
- }
140
- if (!doc.contents) {
141
- throw new Error('Failed to parse pipeline document');
142
- }
143
- // Apply path-based operations
144
- const operations = [
145
- // =============================================================================
146
- // WORKFLOW HEADER COMMENT
147
- // Note: This comment should be preserved if user has custom comments at
148
- // document level, only job-level managed headers should replace user comments
149
- // =============================================================================
150
- {
151
- path: 'name',
152
- operation: 'preserve',
153
- value: (() => {
154
- const nameScalar = new Scalar('Pipeline');
155
- nameScalar.type = Scalar.QUOTE_DOUBLE;
156
- return nameScalar;
157
- })(),
158
- required: true
159
- // Note: No commentBefore here - we'll add it only if no user comment exists
160
- },
161
- // =============================================================================
162
- // WORKFLOW METADATA - Name and run identification
163
- // =============================================================================
164
- {
165
- path: 'run-name',
166
- operation: 'preserve',
167
- value: (() => {
168
- const branchList = branchFlow.join(',');
169
- const runNameScalar = new Scalar(`\${{ github.event_name == 'pull_request' && !contains('${branchList}', github.head_ref) && github.event.pull_request.title || github.ref_name }} #\${{ inputs.run_number || github.run_number }}\${{ inputs.version && format(' - {0}', inputs.version) || '' }}`);
170
- runNameScalar.type = Scalar.QUOTE_DOUBLE;
171
- return runNameScalar;
172
- })(),
173
- required: true,
174
- spaceBefore: true
175
- },
176
- // =============================================================================
177
- // WORKFLOW TRIGGERS - Define when the pipeline runs
178
- // =============================================================================
179
- // The pipeline runs on:
180
- // 1. pull_request (opened/synchronize/reopened) targeting initial branch only
181
- // - Excludes 'closed' type to avoid duplicate runs when PR is merged
182
- // - Only targets initial branch (e.g., develop) to avoid duplicates
183
- // - Automated PRs (develop→staging, staging→main) don't trigger (wrong target)
184
- // - Only runs changes detection + tests (no versioning/tagging/promotion)
185
- // 2. push to branch flow branches - Runs full pipeline after PR merge
186
- // - Includes versioning, tagging, PR creation to next branch, and promotion
187
- // 3. workflow_dispatch - Manual trigger with full pipeline
188
- // 4. workflow_call - Can be called from other workflows
189
- //
190
- // Flow example:
191
- // feature/xyz → PR to develop → Tests run (targets develop ✓)
192
- // PR merged → Push to develop → Full pipeline (version + tag + createpr)
193
- // Pipecraft creates PR: develop → staging (targets staging, skipped ✓)
194
- // Auto-merge → Push to staging → Full pipeline continues
195
- // Pipecraft creates PR: staging → main (targets main, skipped ✓)
196
- // Auto-merge → Push to main → Full pipeline completes
197
- // Ensure 'on' key exists with proper spacing (nested operations below will populate it)
198
- {
199
- path: 'on',
200
- operation: 'set',
201
- value: {},
202
- required: true,
203
- spaceBefore: true
204
- },
205
- {
206
- path: 'on.workflow_dispatch.inputs.version',
207
- operation: 'set',
208
- value: {
209
- description: 'The version to deploy',
210
- required: false,
211
- type: 'string'
212
- },
213
- required: true
214
- },
215
- {
216
- path: 'on.workflow_dispatch.inputs.baseRef',
217
- operation: 'set',
218
- value: {
219
- description: 'The base reference for comparison',
220
- required: false,
221
- type: 'string'
222
- },
223
- required: true
224
- },
225
- {
226
- path: 'on.workflow_dispatch.inputs.run_number',
227
- operation: 'set',
228
- value: {
229
- description: 'The original run number from develop branch',
230
- required: false,
231
- type: 'string'
232
- },
233
- required: true
234
- },
235
- {
236
- path: 'on.workflow_dispatch.inputs.commitSha',
237
- operation: 'set',
238
- value: {
239
- description: 'The exact commit SHA to checkout and test',
240
- required: false,
241
- type: 'string'
242
- },
243
- required: true
244
- },
245
- {
246
- path: 'on.workflow_call.inputs.version',
247
- operation: 'set',
248
- value: {
249
- description: 'The version to deploy',
250
- required: false,
251
- type: 'string'
252
- },
253
- required: true
254
- },
255
- {
256
- path: 'on.workflow_call.inputs.baseRef',
257
- operation: 'set',
258
- value: {
259
- description: 'The base reference for comparison',
260
- required: false,
261
- type: 'string'
262
- },
263
- required: true
264
- },
265
- {
266
- path: 'on.workflow_call.inputs.run_number',
267
- operation: 'set',
268
- value: {
269
- description: 'The original run number from develop branch',
270
- required: false,
271
- type: 'string'
272
- },
273
- required: true
274
- },
275
- {
276
- path: 'on.workflow_call.inputs.commitSha',
277
- operation: 'set',
278
- value: {
279
- description: 'The exact commit SHA to checkout and test',
280
- required: false,
281
- type: 'string'
282
- },
283
- required: true
284
- },
285
- {
286
- path: 'on.push.branches',
287
- operation: 'set',
288
- value: branchFlow,
289
- required: true
290
- },
291
- {
292
- path: 'on.pull_request.types',
293
- operation: 'set',
294
- value: ['opened', 'synchronize', 'reopened'],
295
- required: true
296
- },
297
- {
298
- path: 'on.pull_request.branches',
299
- operation: 'set',
300
- value: [ctx.initialBranch || branchFlow[0]],
301
- required: true
302
- },
303
- // =============================================================================
304
- // CORE PIPECRAFT JOBS - Template-managed jobs that get updates
305
- // =============================================================================
306
- // These are the core Pipecraft jobs that should always use the latest template
307
- // version. Using 'overwrite' operation ensures users get bug fixes and improvements.
308
- // These jobs are essential for Pipecraft functionality and should not be customized.
309
- // Ensure 'jobs' key exists with proper spacing (nested operations below will populate it)
310
- {
311
- path: 'jobs',
312
- operation: 'set',
313
- value: {},
314
- required: true,
315
- spaceBefore: true
316
- },
317
- {
318
- path: 'jobs.changes',
319
- operation: 'overwrite',
320
- value: createValueFromString(`
321
- runs-on: ubuntu-latest
322
- steps:
323
- - uses: actions/checkout@v4
324
- with:
325
- ref: \${{ inputs.commitSha || github.sha }}
326
- - uses: ./.github/actions/detect-changes
327
- id: detect
328
- with:
329
- baseRef: \${{ inputs.baseRef || '${ctx.finalBranch || "main"}' }}
330
- outputs:
331
- ${Object.keys(ctx.domains || {}).sort().map((domain) => ` ${domain}: \${{ steps.detect.outputs.${domain} }}`).join('\n')}
332
- `, ctx),
333
- commentBefore: dedent `
334
- =============================================================================
335
- CHANGES DETECTION (⚠️ Managed by Pipecraft - do not modify)
336
- =============================================================================
337
- This job detects which domains have changed and sets outputs for downstream jobs.
338
- `,
339
- required: true
340
- },
341
- // =============================================================================
342
- // USER-MANAGED SECTIONS - Preserve user customizations
343
- // =============================================================================
344
- // These sections are designed for user customizations (testing, deployment).
345
- // Using 'preserve' operation to keep any existing user jobs while providing
346
- // template structure and examples for new users.
347
- // Generate test jobs for each domain (only if testable: true)
348
- ...Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].testable !== false).map((domain) => ({
349
- path: `jobs.test-${domain}`,
350
- operation: 'preserve',
351
- value: createValueFromString(`
352
- needs: changes
353
- if: \${{ needs.changes.outputs.${domain} == 'true' }}
354
- runs-on: ubuntu-latest
355
- steps:
356
- # TODO: Replace with your ${domain} test logic
357
- - name: Run ${domain} tests
358
- run: |
359
- echo "Running tests for ${domain} domain"
360
- echo "Replace this with your actual test commands"
361
- # Example: npm test -- --testPathPattern=${domain}
362
- `, ctx),
363
- commentBefore: domain === Object.keys(ctx.domains || {}).sort()[0] ? dedent `
364
-
365
-
366
- =============================================================================
367
- TESTING JOBS (✅ Customize these with your test logic)
368
- =============================================================================
369
- These jobs run tests for each domain when changes are detected.
370
- Replace the TODO comments with your actual test commands.
371
- ` : undefined,
372
- required: true
373
- })),
374
- {
375
- path: 'jobs.version',
376
- operation: 'overwrite',
377
- commentBefore: dedent `
378
- =============================================================================
379
- VERSIONING (⚠️ Managed by Pipecraft - do not modify)
380
- =============================================================================
381
- Calculates the next version based on conventional commits and semver rules.
382
- Only runs on push events (skipped on pull requests).
383
- `,
384
- value: createValueFromString(`
385
- if: \${{ always() && github.event_name != 'pull_request' && (${Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].testable !== false).map((domain) => `needs.test-${domain}.result == 'success'`).join(' || ')}) && ${Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].testable !== false).map((domain) => `needs.test-${domain}.result != 'failure'`).join(' && ')} }}
386
- needs: [ changes, ${Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].testable !== false).map((domain) => `test-${domain}`).join(', ')} ]
387
- runs-on: ubuntu-latest
388
- steps:
389
- - uses: actions/checkout@v4
390
- with:
391
- ref: \${{ inputs.commitSha || github.sha }}
392
- - uses: ./.github/actions/calculate-version
393
- id: version
394
- with:
395
- baseRef: \${{ inputs.baseRef || '${ctx.finalBranch || "main"}' }}
396
- commitSha: \${{ inputs.commitSha || github.sha }}
397
- outputs:
398
- version: \${{ steps.version.outputs.version }}
399
- `, ctx),
400
- required: true,
401
- spaceBefore: true,
402
- },
403
- // Generate deployment jobs for each domain (only if deployable: true)
404
- ...Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].deployable === true).map((domain, index) => ({
405
- path: `jobs.deploy-${domain}`,
406
- operation: 'overwrite',
407
- commentBefore: index === 0 ? dedent `
408
- =============================================================================
409
- DEPLOYMENT JOBS (✅ Customize these with your deploy logic)
410
- =============================================================================
411
- These jobs deploy each domain when changes are detected and tests pass.
412
- Replace the TODO comments with your actual deployment commands.
413
- ` : undefined,
414
- spaceBefore: index === 0 ? true : undefined,
415
- value: createValueFromString(`
416
- needs: [ version, changes ]
417
- if: \${{ always() && needs.version.result == 'success' && needs.changes.outputs.${domain} == 'true' }}
418
- runs-on: ubuntu-latest
419
- steps:
420
- - name: Deploy ${domain}
421
- run: |
422
- echo "Deploying ${domain}"
423
- echo "Replace this with your actual deploy commands"
424
- # Example: npm deploy -- --testPathPattern=${domain}
425
- `, ctx),
426
- required: true
427
- })),
428
- // Generate remote testing jobs for each domain (only if remoteTestable: true)
429
- ...Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].remoteTestable === true).map((domain, index) => ({
430
- path: `jobs.remote-test-${domain}`,
431
- operation: 'overwrite',
432
- commentBefore: index === 0 ? dedent `
433
- =============================================================================
434
- REMOTE TESTING JOBS (✅ Customize these with your remote test logic)
435
- =============================================================================
436
- These jobs test deployed services remotely after deployment succeeds.
437
- Replace the TODO comments with your actual remote testing commands.
438
- ` : undefined,
439
- spaceBefore: index === 0 ? true : undefined,
440
- value: createValueFromString(`
441
- needs: [ deploy-${domain}, changes ]
442
- if: \${{ always() }}
443
- runs-on: ubuntu-latest
444
- steps:
445
- - name: Test ${domain}
446
- if: \${{ needs.changes.outputs.${domain} == 'true' && needs.deploy-${domain}.result == 'success' }}
447
- run: |
448
- echo "Testing ${domain} remotely"
449
- echo "Replace this with your actual test commands"
450
- # Example: npm test -- --testPathPattern=${domain}
451
- `, ctx),
452
- required: true
453
- })),
454
- {
455
- path: 'jobs.tag',
456
- operation: 'overwrite',
457
- value: (() => {
458
- // Build list of deploy and remote-test jobs that tag depends on
459
- const deployJobs = Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].deployable === true).map((domain) => `deploy-${domain}`);
460
- const remoteTestJobs = Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].remoteTestable === true).map((domain) => `remote-test-${domain}`);
461
- const allDeployTestJobs = [...deployJobs, ...remoteTestJobs];
462
- // Build needs array (version + all deploy/remote-test jobs)
463
- const needsArray = ['version', ...allDeployTestJobs];
464
- // Build conditional: no failures AND at least one success
465
- const noFailures = allDeployTestJobs.length > 0
466
- ? allDeployTestJobs.map((job) => `needs.${job}.result != 'failure'`).join(' && \n ')
467
- : 'true';
468
- const atLeastOneSuccess = allDeployTestJobs.length > 0
469
- ? allDeployTestJobs.map((job) => `needs.${job}.result == 'success'`).join(' || \n ')
470
- : 'true';
471
- return createValueFromString(`
472
- # Needs all deploy and/or remote test jobs to succeed or be skipped
473
- # Needs at least one domain to succeed
474
- needs: [ ${needsArray.join(', ')} ]
475
- if: \${{
476
- always() &&
477
- github.event_name != 'pull_request' &&
478
- github.ref_name == '${ctx.initialBranch || branchFlow[0]}' &&
479
- needs.version.result == 'success' &&
480
- needs.version.outputs.version != '' &&
481
- (
482
- ${noFailures}
483
- ) &&
484
- (
485
- ${atLeastOneSuccess}
486
- )
487
- }}
488
- runs-on: ubuntu-latest
489
- steps:
490
- - uses: actions/checkout@v4
491
- with:
492
- ref: \${{ inputs.commitSha || github.sha }}
493
- - uses: ./.github/actions/create-tag
494
- with:
495
- version: \${{ needs.version.outputs.version }}
496
- `, ctx);
497
- })(),
498
- spaceBefore: true,
499
- commentBefore: dedent `
500
- =============================================================================
501
- TAG & PROMOTE (⚠️ Managed by Pipecraft - do not modify)
502
- =============================================================================
503
- Creates a git tag with the calculated version on the initial branch.
504
- Only runs on push events after successful tests and deployments.
505
- `,
506
- required: true
507
- },
508
- // Generate single promotion job that handles all branch transitions dynamically
509
- {
510
- path: 'jobs.promote',
511
- operation: 'overwrite',
512
- value: createValueFromString(`
513
- # Only runs on push or manual workflow_dispatch events to branches that can promote
514
- # Requires version to succeed (which means tests passed)
515
- # Requires a version to have been calculated (skip promotion for non-versioned commits)
516
- # Needs all deploy and/or remote test jobs to succeed
517
- if: \${{
518
- always() &&
519
- (github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
520
- needs.version.result == 'success' &&
521
- needs.version.outputs.version != '' &&
522
- (needs.tag.result == 'success' || needs.tag.result == 'skipped') &&
523
- (
524
- ${branchFlow.slice(0, -1).map((branch) => `github.ref_name == '${branch}'`).join(' || \n ')}
525
- )
526
- }}
527
- needs: [ version, tag ]
528
- runs-on: ubuntu-latest
529
- steps:
530
- - uses: actions/checkout@v4
531
- with:
532
- ref: \${{ inputs.commitSha || github.sha }}
533
- - uses: ./.github/actions/promote-branch
534
- with:
535
- sourceBranch: \${{ github.ref_name }}
536
- version: \${{ needs.version.outputs.version }}
537
- run_number: \${{ inputs.run_number || github.run_number }}
538
- token: \${{ secrets.GITHUB_TOKEN }}
539
- `, ctx),
540
- spaceBefore: true,
541
- commentBefore: `=============================================================================
542
- PROMOTION JOB (⚠️ Managed by Pipecraft - do not modify)
543
- =============================================================================
544
- Triggers the next branch's workflow after successful versioning and tagging.
545
- Passes version and run_number to maintain traceability across branches.`,
546
- required: true
547
- },
548
- // Generate release job for final branch (main)
549
- {
550
- path: 'jobs.release',
551
- operation: 'overwrite',
552
- value: createValueFromString(`
553
- # Create GitHub release on main branch after successful tests and versioning
554
- if: \${{
555
- always() &&
556
- github.ref_name == '${ctx.finalBranch || branchFlow[branchFlow.length - 1]}' &&
557
- (github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
558
- needs.version.result == 'success' &&
559
- needs.version.outputs.version != '' &&
560
- (needs.tag.result == 'success' || needs.tag.result == 'skipped')
561
- }}
562
- needs: [ version, tag ]
563
- runs-on: ubuntu-latest
564
- steps:
565
- - uses: actions/checkout@v4
566
- with:
567
- ref: \${{ inputs.commitSha || github.sha }}
568
- - uses: ./.github/actions/create-release
569
- with:
570
- version: \${{ needs.version.outputs.version }}
571
- token: \${{ secrets.GITHUB_TOKEN }}
572
- `, ctx),
573
- spaceBefore: true,
574
- commentBefore: `=============================================================================
575
- RELEASE JOB (⚠️ Managed by Pipecraft - do not modify)
576
- =============================================================================
577
- Creates a GitHub release on the final branch with release notes.
578
- Only runs after successful versioning and tagging on the final branch.`,
579
- required: true
580
- },
581
- ];
582
- // Helper function to get job keys in order
583
- const getJobKeysInOrder = (jobsNode) => {
584
- if (!jobsNode || !jobsNode.items)
585
- return [];
586
- return jobsNode.items
587
- .filter((item) => item.key)
588
- .map((item) => item.key.toString());
589
- };
590
- // Unified approach: Use the operation system for all Pipecraft jobs
591
- // The operation system already handles:
592
- // - If key exists and is owned by Pipecraft → overwrite it
593
- // - If key doesn't exist → create it
594
- // - If key isn't part of Pipecraft → ignore it (via job filtering)
595
- const PIPECRAFT_OWNED_JOBS = getPipecraftOwnedJobs(branchFlow, ctx.domains);
596
- // Capture original job order before any modifications
597
- let originalJobOrder = [];
598
- if (doc.contents.get('jobs')) {
599
- const jobsNode = doc.contents.get('jobs');
600
- if (jobsNode && jobsNode.items) {
601
- originalJobOrder = getJobKeysInOrder(jobsNode);
602
- logger.debug('📋 Original job order:', originalJobOrder);
603
- }
604
- }
605
- // Deprecated jobs that should be removed (old promotion strategy)
606
- const DEPRECATED_JOBS = new Set(['createpr', 'branch']);
607
- // Collect user jobs (non-Pipecraft jobs) to preserve them
608
- // Save the entire item (key, value, and comments) not just the value
609
- const userJobs = new Map();
610
- if (doc.contents.get('jobs')) {
611
- const jobsNode = doc.contents.get('jobs');
612
- if (jobsNode && jobsNode.items) {
613
- for (const item of jobsNode.items) {
614
- const jobName = item.key?.toString() || item.key?.value;
615
- // Skip deprecated jobs - don't preserve them
616
- if (jobName && !PIPECRAFT_OWNED_JOBS.has(jobName) && !DEPRECATED_JOBS.has(jobName)) {
617
- // Save the entire item to preserve comments on keys
618
- userJobs.set(jobName, item);
619
- }
620
- }
621
- if (userJobs.size > 0) {
622
- logger.verbose(`📋 Preserving ${userJobs.size} user jobs: ${Array.from(userJobs.keys()).join(', ')}`);
623
- }
624
- }
625
- }
626
- // To ensure proper key order (name, run-name, on, jobs), we need to:
627
- // 1. Extract all values we care about
628
- // 2. Clear the document
629
- // 3. Re-add them in the correct order
630
- // Save existing values and comments that should be preserved
631
- const existingName = doc.contents.get('name');
632
- const existingRunName = doc.contents.get('run-name');
633
- const existingOn = doc.contents.get('on');
634
- const existingJobs = doc.contents.get('jobs');
635
- // Save jobs node comments (before we rebuild)
636
- const jobsNodeCommentBefore = existingJobs ? existingJobs.commentBefore : undefined;
637
- const jobsNodeComment = existingJobs ? existingJobs.comment : undefined;
638
- // Save document-level comments (user comments at the top)
639
- // Note: YAML stores document-level comments on doc.commentBefore, not doc.contents.commentBefore
640
- const docCommentBefore = doc.commentBefore;
641
- const docComment = doc.comment;
642
- // Save user comments on keys (but not Pipecraft-managed comments)
643
- const savedComments = new Map();
644
- const isPipecraftComment = (comment) => {
645
- if (!comment)
646
- return false;
647
- const lowerComment = comment.toLowerCase();
648
- return lowerComment.includes('pipecraft') ||
649
- lowerComment.includes('managed by pipecraft') ||
650
- lowerComment.includes('do not modify');
651
- };
652
- for (const item of doc.contents.items) {
653
- const keyName = typeof item.key === 'string' ? item.key : item.key?.value;
654
- if (keyName) {
655
- const keyCommentBefore = item.key?.commentBefore;
656
- const keyComment = item.key?.comment;
657
- // Only save non-Pipecraft comments
658
- if ((keyCommentBefore && !isPipecraftComment(keyCommentBefore)) ||
659
- (keyComment && !isPipecraftComment(keyComment))) {
660
- savedComments.set(keyName, {
661
- commentBefore: keyCommentBefore && !isPipecraftComment(keyCommentBefore) ? keyCommentBefore : undefined,
662
- comment: keyComment && !isPipecraftComment(keyComment) ? keyComment : undefined
663
- });
664
- }
665
- }
666
- }
667
- // Clear the document to rebuild with correct key order
668
- doc.contents.items = [];
669
- // Apply root-level operations to set name, run-name, on
670
- const rootOperations = operations.filter(op => !op.path.startsWith('jobs.'));
671
- applyPathOperations(doc.contents, rootOperations, doc);
672
- // Restore document-level comments (user comments at the top of file)
673
- // Only restore if they're not Pipecraft comments
674
- const hasUserDocComment = docCommentBefore && !isPipecraftComment(docCommentBefore);
675
- if (hasUserDocComment) {
676
- ;
677
- doc.commentBefore = docCommentBefore;
678
- }
679
- else {
680
- // No user comment at document level, add Pipecraft workflow header
681
- const pipecraftHeader = `=============================================================================
682
- PIPECRAFT MANAGED WORKFLOW
683
- =============================================================================
684
-
685
- ✅ YOU CAN CUSTOMIZE:
686
- - test-*** jobs for each domain
687
- - deploy-*** jobs for each domain
688
- - remote-test-*** jobs for each domain
689
- - Workflow name
690
-
691
- ⚠️ PIPECRAFT MANAGES (do not modify):
692
- - Workflow triggers, job dependencies, and conditionals
693
- - Changes detection, version calculation, and tag creation
694
- - CreatePR, branch management, promote, and release jobs
695
-
696
- 📌 VERSION PROMOTION BEHAVIOR:
697
- - Only commits that trigger a version bump will promote to staging/main
698
- - Non-versioned commits (test, build, etc.) remain on develop
699
- - This keeps staging/main aligned with tagged releases
700
-
701
- Running 'pipecraft generate' updates managed sections while preserving
702
- your customizations in test/deploy/remote-test jobs.
703
-
704
- 📖 Learn more: https://pipecraft.thecraftlab.dev
705
- =============================================================================`;
706
- doc.commentBefore = pipecraftHeader;
707
- }
708
- if (docComment && !isPipecraftComment(docComment)) {
709
- ;
710
- doc.comment = docComment;
711
- }
712
- // Replace values for preserve operations that had existing values
713
- // This maintains the order and comments from operations, but uses the preserved values
714
- // Only do this if there was an actual existing pipeline (not from base template)
715
- if (hasExistingPipeline) {
716
- if (existingName !== undefined && existingName !== null) {
717
- // Find the 'name' key and replace its value
718
- const nameIndex = doc.contents.items.findIndex((item) => {
719
- const key = item.key;
720
- if (typeof key === 'string')
721
- return key === 'name';
722
- if (key && typeof key.value === 'string')
723
- return key.value === 'name';
724
- return false;
725
- });
726
- if (nameIndex >= 0) {
727
- doc.contents.items[nameIndex].value = existingName;
728
- }
729
- }
730
- if (existingRunName !== undefined && existingRunName !== null) {
731
- // Find the 'run-name' key and replace its value
732
- const runNameIndex = doc.contents.items.findIndex((item) => {
733
- const key = item.key;
734
- if (typeof key === 'string')
735
- return key === 'run-name';
736
- if (key && typeof key.value === 'string')
737
- return key.value === 'run-name';
738
- return false;
739
- });
740
- if (runNameIndex >= 0) {
741
- doc.contents.items[runNameIndex].value = existingRunName;
742
- }
743
- }
744
- }
745
- // Restore user comments on keys (but don't overwrite Pipecraft comments)
746
- for (const item of doc.contents.items) {
747
- const keyName = typeof item.key === 'string' ? item.key : item.key?.value;
748
- if (keyName && savedComments.has(keyName)) {
749
- const saved = savedComments.get(keyName);
750
- const currentKey = item.key;
751
- // Only restore user comments if current comment is Pipecraft-managed or missing
752
- const currentCommentBefore = currentKey?.commentBefore;
753
- const currentComment = currentKey?.comment;
754
- if (saved.commentBefore && (!currentCommentBefore || !isPipecraftComment(currentCommentBefore))) {
755
- ;
756
- currentKey.commentBefore = saved.commentBefore;
757
- }
758
- if (saved.comment && (!currentComment || !isPipecraftComment(currentComment))) {
759
- ;
760
- currentKey.comment = saved.comment;
761
- }
762
- }
763
- }
764
- // Note: We don't restore 'on' or 'jobs' here because the operations already created them
765
- // with proper spacing. Restoring them would overwrite the Scalar keys and lose spacing.
766
- // Clear the jobs section to rebuild in correct order
767
- const jobsNode = doc.contents.get('jobs');
768
- if (jobsNode && jobsNode.items) {
769
- jobsNode.items = [];
770
- }
771
- // Apply job operations
772
- const jobOperations = operations.filter(op => op.path.startsWith('jobs.'));
773
- applyPathOperations(doc.contents, jobOperations, doc);
774
- // Now we need to reorder jobs to match the original order
775
- // Collect all current jobs (Pipecraft jobs that were just created)
776
- const currentJobs = new Map();
777
- if (jobsNode && jobsNode.items) {
778
- for (const item of jobsNode.items) {
779
- const jobName = item.key?.toString();
780
- if (jobName) {
781
- currentJobs.set(jobName, item);
782
- }
783
- }
784
- }
785
- // Clear again to rebuild in correct order
786
- if (jobsNode && jobsNode.items) {
787
- jobsNode.items = [];
788
- }
789
- // Rebuild jobs in original order
790
- // For each job in the original order:
791
- // - If it's a Pipecraft job, use the newly created version from currentJobs
792
- // - If it's a user job, use the preserved version from userJobs
793
- // - Skip deprecated jobs
794
- // IMPORTANT: Ensure all keys are Scalars (not strings) so we can add comments later
795
- for (const jobName of originalJobOrder) {
796
- // Skip deprecated jobs
797
- if (DEPRECATED_JOBS.has(jobName)) {
798
- logger.verbose(`🗑️ Removing deprecated job: ${jobName}`);
799
- continue;
800
- }
801
- if (PIPECRAFT_OWNED_JOBS.has(jobName)) {
802
- // It's a Pipecraft job - use the newly created version
803
- const item = currentJobs.get(jobName);
804
- if (item && jobsNode) {
805
- // Ensure the key is a Scalar, not a string
806
- if (typeof item.key === 'string') {
807
- item.key = new Scalar(item.key);
808
- }
809
- jobsNode.items.push(item);
810
- }
811
- }
812
- else {
813
- // It's a user job - re-insert the entire item (with comments)
814
- const jobItem = userJobs.get(jobName);
815
- if (jobItem && jobsNode) {
816
- // Ensure the key is a Scalar, not a string
817
- if (typeof jobItem.key === 'string') {
818
- jobItem.key = new Scalar(jobItem.key);
819
- }
820
- jobsNode.items.push(jobItem);
821
- }
822
- }
823
- }
824
- // Add any new Pipecraft jobs that weren't in the original order (at the end)
825
- for (const [jobName, item] of currentJobs) {
826
- if (!originalJobOrder.includes(jobName) && jobsNode) {
827
- // Ensure the key is a Scalar, not a string
828
- if (typeof item.key === 'string') {
829
- item.key = new Scalar(item.key);
830
- }
831
- jobsNode.items.push(item);
832
- }
833
- }
834
- // Get the final jobs node to restore comments
835
- const finalJobsNode = doc.contents.get('jobs');
836
- // Restore user comments on the jobs node (if they weren't Pipecraft comments)
837
- if (jobsNodeCommentBefore && !isPipecraftComment(jobsNodeCommentBefore) && finalJobsNode) {
838
- ;
839
- finalJobsNode.commentBefore = jobsNodeCommentBefore;
840
- logger.debug('✅ Restored comment to jobs node');
841
- }
842
- if (jobsNodeComment && !isPipecraftComment(jobsNodeComment) && finalJobsNode) {
843
- ;
844
- finalJobsNode.comment = jobsNodeComment;
845
- }
846
- // Log final job order (after operations are applied)
847
- if (finalJobsNode && finalJobsNode.items && finalJobsNode.items.length > 0) {
848
- const jobNames = getJobKeysInOrder(finalJobsNode);
849
- if (jobNames.length > 0) {
850
- logger.debug('📋 Final job order:', jobNames);
851
- }
852
- // Remove duplicate Pipecraft comment headers from values
853
- // When we reuse existing job values, they may carry old Pipecraft comments from parsing
854
- // We want to clear Pipecraft comments but preserve user comments
855
- for (const item of finalJobsNode.items) {
856
- if (item.value && item.value.commentBefore) {
857
- const valueComment = item.value.commentBefore;
858
- // Only clear if it's a Pipecraft-managed comment
859
- if (isPipecraftComment(valueComment)) {
860
- delete item.value.commentBefore;
861
- }
862
- }
863
- }
864
- // Add section headers and spacing to domain-based jobs
865
- // This ensures headers appear in the right place and jobs have proper spacing
866
- const domainKeys = Object.keys(ctx.domains || {});
867
- if (domainKeys.length > 0) {
868
- let foundFirstTest = false;
869
- let foundFirstDeploy = false;
870
- let foundFirstRemoteTest = false;
871
- for (const item of finalJobsNode.items) {
872
- const jobName = item.key?.toString();
873
- if (!jobName)
874
- continue;
875
- // Skip if key is a string (can't add properties to primitive strings)
876
- if (typeof item.key === 'string')
877
- continue;
878
- // Handle test-* jobs
879
- if (jobName.startsWith('test-')) {
880
- if (!foundFirstTest) {
881
- // First test job gets the header
882
- item.key.commentBefore = `
883
-
884
-
885
- =============================================================================
886
- TESTING JOBS
887
- =============================================================================`;
888
- item.key.spaceBefore = true;
889
- foundFirstTest = true;
890
- }
891
- else {
892
- // Subsequent test jobs get a blank line
893
- ;
894
- item.key.spaceBefore = true;
895
- }
896
- }
897
- // Handle deploy-* jobs
898
- if (jobName.startsWith('deploy-')) {
899
- if (!foundFirstDeploy) {
900
- // First deploy job gets the header
901
- ;
902
- item.key.commentBefore = `
903
-
904
-
905
- =============================================================================
906
- DEPLOYMENT JOBS
907
- =============================================================================`;
908
- item.key.spaceBefore = true;
909
- foundFirstDeploy = true;
910
- }
911
- else {
912
- // Subsequent deploy jobs get a blank line
913
- ;
914
- item.key.spaceBefore = true;
915
- }
916
- }
917
- // Handle remote-test-* jobs
918
- if (jobName.startsWith('remote-test-')) {
919
- if (!foundFirstRemoteTest) {
920
- // First remote-test job gets the header
921
- ;
922
- item.key.commentBefore = `
923
-
924
-
925
- =============================================================================
926
- REMOTE TESTING JOBS
927
- =============================================================================`;
928
- item.key.spaceBefore = true;
929
- foundFirstRemoteTest = true;
930
- }
931
- else {
932
- // Subsequent remote-test jobs get a blank line
933
- ;
934
- item.key.spaceBefore = true;
935
- }
936
- }
937
- }
938
- }
939
- }
940
- // Generate final content with comment preservation
941
- // Use lineWidth: 0 to prevent line wrapping of long expressions
942
- // This keeps GitHub Actions expressions on a single line
943
- let finalContent = stringify(doc, {
944
- lineWidth: 0,
945
- minContentWidth: 0
946
- });
947
- // Post-process: Format long GitHub Actions conditionals for better readability
948
- // The YAML library doesn't handle newlines well in flow scalars, so we format after stringify
949
- finalContent = finalContent.replace(/if: \$\{\{([^}]+)\}\}/g, (match, condition) => {
950
- // Only format if the condition is long enough to benefit from formatting
951
- if (condition.length < 100)
952
- return match;
953
- let formatted = condition.trim();
954
- // Step 1: Protect function calls like always() by replacing with placeholders
955
- const functionCalls = [];
956
- formatted = formatted.replace(/(\w+)\(\)/g, (match) => {
957
- const placeholder = `__FUNC_${functionCalls.length}__`;
958
- functionCalls.push(match);
959
- return placeholder;
960
- });
961
- // Step 2: Add line breaks for logical operators
962
- formatted = formatted.replace(/\s+&&\s+/g, ' &&\n ');
963
- formatted = formatted.replace(/\s+\|\|\s+/g, ' ||\n ');
964
- // Step 3: Format grouping parentheses (now that function calls are protected)
965
- formatted = formatted.replace(/\(\s*/g, '(\n ');
966
- formatted = formatted.replace(/\s*\)\s*(&&|\|\|)/g, '\n ) $1');
967
- formatted = formatted.replace(/\s*\)(\s*)$/g, '\n )');
968
- // Step 4: Restore function calls
969
- functionCalls.forEach((funcCall, index) => {
970
- formatted = formatted.replace(`__FUNC_${index}__`, funcCall);
971
- });
972
- return `if: $\{{\n ${formatted}\n }}`;
973
- });
974
- return {
975
- yamlContent: finalContent,
976
- mergeStatus: hasExistingPipeline ? 'merged' : 'overwritten'
977
- };
978
- };
979
- /**
980
- * Load existing pipeline file
981
- */
982
- const loadExistingPipeline = (filePath) => {
983
- if (!fs.existsSync(filePath)) {
984
- return null;
985
- }
986
- return fs.readFileSync(filePath, 'utf8');
987
- };
988
- /**
989
- * Main pipeline generator entry point.
990
- *
991
- * Generates the complete GitHub Actions pipeline workflow with intelligent
992
- * merging of existing user customizations.
993
- *
994
- * @param {PinionContext & { existingPipeline?: any, outputPipelinePath?: string }} ctx - Generator context
995
- * @param {any} [ctx.existingPipeline] - Parsed existing pipeline YAML for merging
996
- * @param {string} [ctx.existingPipelineContent] - Raw existing pipeline content for comment preservation
997
- * @param {string} [ctx.outputPipelinePath] - Custom output path (default: .github/workflows/pipeline.yml)
998
- * @param {string[]} ctx.branchFlow - Branch flow sequence (e.g., ['develop', 'staging', 'main'])
999
- * @param {Record<string, DomainConfig>} ctx.domains - Domain configurations for change detection
1000
- * @param {string} [ctx.ciProvider] - CI provider ('github' or 'gitlab')
1001
- * @param {string} [ctx.mergeStrategy] - Merge strategy ('fast-forward' or 'merge')
1002
- * @returns {Promise<PinionContext>} Updated context with generated YAML
1003
- *
1004
- * @throws {Error} If pipeline file cannot be written
1005
- * @throws {Error} If existing pipeline cannot be parsed
1006
- *
1007
- * @example
1008
- * ```typescript
1009
- * // Generate new pipeline
1010
- * await generate({
1011
- * cwd: '/path/to/project',
1012
- * branchFlow: ['develop', 'main'],
1013
- * domains: {
1014
- * api: { paths: ['src/api/**'], test: true }
1015
- * }
1016
- * })
1017
- *
1018
- * // Update existing pipeline (preserves user jobs)
1019
- * const existing = parseDocument(readFileSync('pipeline.yml', 'utf8'))
1020
- * await generate({
1021
- * cwd: '/path/to/project',
1022
- * existingPipeline: existing,
1023
- * existingPipelineContent: readFileSync('pipeline.yml', 'utf8'),
1024
- * branchFlow: ['develop', 'staging', 'main'],
1025
- * domains: { ... }
1026
- * })
1027
- * ```
1028
- *
1029
- * @note The generator performs these steps:
1030
- * 1. Calls `createPathBasedPipeline()` to build the workflow
1031
- * 2. Logs merge status (new vs. merged)
1032
- * 3. Writes the final YAML to the output path
1033
- *
1034
- * The heavy lifting is done by `createPathBasedPipeline()` which handles:
1035
- * - Job generation based on domains and branch flow
1036
- * - User job preservation and merging
1037
- * - Comment preservation from existing pipeline
1038
- * - Intelligent job ordering
1039
- */
1040
- export const generate = (ctx) => Promise.resolve(ctx)
1041
- .then((ctx) => {
1042
- const result = createPathBasedPipeline(ctx);
1043
- return {
1044
- ...ctx,
1045
- yamlContent: result.yamlContent,
1046
- mergeStatus: result.mergeStatus
1047
- };
1048
- })
1049
- .then((ctx) => {
1050
- // Provide user feedback about file operation
1051
- const outputPath = ctx.outputPipelinePath || '.github/workflows/pipeline.yml';
1052
- const status = ctx.mergeStatus === 'merged' ? '🔄 Merged with existing' : '📝 Created new';
1053
- logger.verbose(`${status} ${outputPath}`);
1054
- return ctx;
1055
- })
1056
- .then(renderTemplate((ctx) => ctx.yamlContent, toFile((ctx) => ctx.outputPipelinePath || '.github/workflows/pipeline.yml')));
1057
- //# sourceMappingURL=pipeline-path-based.yml.tpl.js.map