pipecraft 0.0.0-releaseit

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 (176) hide show
  1. package/.claude/settings.local.json +35 -0
  2. package/.github/actions/calculate-version/action.yml +106 -0
  3. package/.github/actions/create-pr/action.yml +122 -0
  4. package/.github/actions/create-release/action.yml +74 -0
  5. package/.github/actions/create-tag/action.yml +94 -0
  6. package/.github/actions/detect-changes/action.yml +62 -0
  7. package/.github/actions/manage-branch/action.yml +113 -0
  8. package/.github/actions/promote-branch/action.yml +352 -0
  9. package/.github/example/workflows/job.analyze.code.yml +26 -0
  10. package/.github/example/workflows/job.analyze.docker.yml +32 -0
  11. package/.github/example/workflows/job.app.api.deploy.yml +127 -0
  12. package/.github/example/workflows/job.app.api.test.yml +102 -0
  13. package/.github/example/workflows/job.app.docs.deploy.yml +40 -0
  14. package/.github/example/workflows/job.app.docs.test.yml +50 -0
  15. package/.github/example/workflows/job.app.web.deploy.yml +96 -0
  16. package/.github/example/workflows/job.app.web.test.yml +49 -0
  17. package/.github/example/workflows/job.changes.yml +82 -0
  18. package/.github/example/workflows/job.create-pr.yml +96 -0
  19. package/.github/example/workflows/job.env-check.yml +43 -0
  20. package/.github/example/workflows/job.fast-forward.yml +103 -0
  21. package/.github/example/workflows/job.lint.yml +64 -0
  22. package/.github/example/workflows/job.pr-name.yml +70 -0
  23. package/.github/example/workflows/job.pr-squash-name.yml +27 -0
  24. package/.github/example/workflows/job.tag.yml +38 -0
  25. package/.github/example/workflows/job.version.yml +99 -0
  26. package/.github/example/workflows/lib.plugin-base.publish.yml +59 -0
  27. package/.github/example/workflows/pipe.yml +178 -0
  28. package/.github/example/workflows/scripts/fastforward.sh +51 -0
  29. package/.github/example/workflows/scripts/pipe-cleaner.sh +70 -0
  30. package/.github/scripts/fastforward.sh +51 -0
  31. package/.github/scripts/pipe-cleaner.sh +70 -0
  32. package/.github/workflows/pipeline.yml +220 -0
  33. package/.github/workflows/pr-title-check.yml +70 -0
  34. package/.github/workflows/publish.yml +39 -0
  35. package/.pipecraftrc.json +78 -0
  36. package/.release-it.cjs +71 -0
  37. package/PIPELINE_TESTING_PLAN.md +499 -0
  38. package/README.md +1101 -0
  39. package/TRUNK_FLOW_PLAN.md +401 -0
  40. package/assets/logo_banner.png +0 -0
  41. package/assets/logo_banner.webp +0 -0
  42. package/dist/cli/index.d.ts +3 -0
  43. package/dist/cli/index.d.ts.map +1 -0
  44. package/dist/cli/index.js +364 -0
  45. package/dist/cli/index.js.map +1 -0
  46. package/dist/generators/init.tpl.d.ts +3 -0
  47. package/dist/generators/init.tpl.d.ts.map +1 -0
  48. package/dist/generators/init.tpl.js +117 -0
  49. package/dist/generators/init.tpl.js.map +1 -0
  50. package/dist/generators/workflows.tpl.d.ts +7 -0
  51. package/dist/generators/workflows.tpl.d.ts.map +1 -0
  52. package/dist/generators/workflows.tpl.js +73 -0
  53. package/dist/generators/workflows.tpl.js.map +1 -0
  54. package/dist/templates/actions/calculate-version.yml.tpl.d.ts +3 -0
  55. package/dist/templates/actions/calculate-version.yml.tpl.d.ts.map +1 -0
  56. package/dist/templates/actions/calculate-version.yml.tpl.js +122 -0
  57. package/dist/templates/actions/calculate-version.yml.tpl.js.map +1 -0
  58. package/dist/templates/actions/create-pr.yml.tpl.d.ts +3 -0
  59. package/dist/templates/actions/create-pr.yml.tpl.d.ts.map +1 -0
  60. package/dist/templates/actions/create-pr.yml.tpl.js +145 -0
  61. package/dist/templates/actions/create-pr.yml.tpl.js.map +1 -0
  62. package/dist/templates/actions/create-tag.yml.tpl.d.ts +3 -0
  63. package/dist/templates/actions/create-tag.yml.tpl.d.ts.map +1 -0
  64. package/dist/templates/actions/create-tag.yml.tpl.js +111 -0
  65. package/dist/templates/actions/create-tag.yml.tpl.js.map +1 -0
  66. package/dist/templates/actions/detect-changes.yml.tpl.d.ts +3 -0
  67. package/dist/templates/actions/detect-changes.yml.tpl.d.ts.map +1 -0
  68. package/dist/templates/actions/detect-changes.yml.tpl.js +79 -0
  69. package/dist/templates/actions/detect-changes.yml.tpl.js.map +1 -0
  70. package/dist/templates/actions/manage-branch.yml.tpl.d.ts +3 -0
  71. package/dist/templates/actions/manage-branch.yml.tpl.d.ts.map +1 -0
  72. package/dist/templates/actions/manage-branch.yml.tpl.js +130 -0
  73. package/dist/templates/actions/manage-branch.yml.tpl.js.map +1 -0
  74. package/dist/templates/actions/promote-branch.yml.tpl.d.ts +3 -0
  75. package/dist/templates/actions/promote-branch.yml.tpl.d.ts.map +1 -0
  76. package/dist/templates/actions/promote-branch.yml.tpl.js +362 -0
  77. package/dist/templates/actions/promote-branch.yml.tpl.js.map +1 -0
  78. package/dist/templates/workflows/pipeline-path-based.yml.tpl.d.ts +16 -0
  79. package/dist/templates/workflows/pipeline-path-based.yml.tpl.d.ts.map +1 -0
  80. package/dist/templates/workflows/pipeline-path-based.yml.tpl.js +652 -0
  81. package/dist/templates/workflows/pipeline-path-based.yml.tpl.js.map +1 -0
  82. package/dist/types/index.d.ts +63 -0
  83. package/dist/types/index.d.ts.map +1 -0
  84. package/dist/types/index.js +2 -0
  85. package/dist/types/index.js.map +1 -0
  86. package/dist/utils/ast-path-operations.d.ts +287 -0
  87. package/dist/utils/ast-path-operations.d.ts.map +1 -0
  88. package/dist/utils/ast-path-operations.js +419 -0
  89. package/dist/utils/ast-path-operations.js.map +1 -0
  90. package/dist/utils/config.d.ts +3 -0
  91. package/dist/utils/config.d.ts.map +1 -0
  92. package/dist/utils/config.js +49 -0
  93. package/dist/utils/config.js.map +1 -0
  94. package/dist/utils/github-setup.d.ts +74 -0
  95. package/dist/utils/github-setup.d.ts.map +1 -0
  96. package/dist/utils/github-setup.js +418 -0
  97. package/dist/utils/github-setup.js.map +1 -0
  98. package/dist/utils/idempotency.d.ts +47 -0
  99. package/dist/utils/idempotency.d.ts.map +1 -0
  100. package/dist/utils/idempotency.js +176 -0
  101. package/dist/utils/idempotency.js.map +1 -0
  102. package/dist/utils/preflight.d.ts +55 -0
  103. package/dist/utils/preflight.d.ts.map +1 -0
  104. package/dist/utils/preflight.js +261 -0
  105. package/dist/utils/preflight.js.map +1 -0
  106. package/dist/utils/versioning.d.ts +41 -0
  107. package/dist/utils/versioning.d.ts.map +1 -0
  108. package/dist/utils/versioning.js +219 -0
  109. package/dist/utils/versioning.js.map +1 -0
  110. package/docs/USER_JOURNEY_ERRORS.md +352 -0
  111. package/eslint.config.js +77 -0
  112. package/examples/basic-config.json +41 -0
  113. package/examples/monorepo-config.json +49 -0
  114. package/examples/usage.md +289 -0
  115. package/package.json +58 -0
  116. package/scripts/debug-workflows.sh +339 -0
  117. package/scripts/parse-pipeline.js +29 -0
  118. package/scripts/test-job-order.cjs +140 -0
  119. package/scripts/validate-pipeline.cjs +379 -0
  120. package/scripts/verify-job-order.sh +30 -0
  121. package/src/cli/index.ts +414 -0
  122. package/src/generators/init.tpl.ts +126 -0
  123. package/src/generators/workflows.tpl.ts +80 -0
  124. package/src/templates/actions/calculate-version.yml.tpl.ts +124 -0
  125. package/src/templates/actions/create-pr.yml.tpl.ts +147 -0
  126. package/src/templates/actions/create-tag.yml.tpl.ts +112 -0
  127. package/src/templates/actions/detect-changes.yml.tpl.ts +87 -0
  128. package/src/templates/actions/manage-branch.yml.tpl.ts +132 -0
  129. package/src/templates/actions/promote-branch.yml.tpl.ts +364 -0
  130. package/src/templates/workflows/pipeline-path-based.yml.tpl.ts +706 -0
  131. package/src/types/index.ts +64 -0
  132. package/src/utils/README-ast-path-operations.md +390 -0
  133. package/src/utils/ast-path-operations.ts +581 -0
  134. package/src/utils/config.ts +64 -0
  135. package/src/utils/github-setup.ts +558 -0
  136. package/src/utils/idempotency.ts +215 -0
  137. package/src/utils/preflight.ts +306 -0
  138. package/src/utils/versioning.ts +244 -0
  139. package/tests/README.md +229 -0
  140. package/tests/TEST_STRUCTURE.md +256 -0
  141. package/tests/act/run-act-tests.sh +345 -0
  142. package/tests/debugging/debug-utils.ts +538 -0
  143. package/tests/debugging/debug-workflow.test.ts +339 -0
  144. package/tests/debugging/debug-workflows.sh +339 -0
  145. package/tests/debugging/iterative-debug.ts +652 -0
  146. package/tests/debugging/run-debug-tests.sh +431 -0
  147. package/tests/fixtures/basic-config.json +51 -0
  148. package/tests/fixtures/invalid-config.json +9 -0
  149. package/tests/fixtures/pipeline-generated.yml +235 -0
  150. package/tests/fixtures/pipeline-preserve-comments.yml +245 -0
  151. package/tests/fixtures/pipeline-user-modified.yml +245 -0
  152. package/tests/fixtures/test-config.json +58 -0
  153. package/tests/github-live/README.md +250 -0
  154. package/tests/github-local/JOB_WORKFLOW_TESTS.md +296 -0
  155. package/tests/github-local/README.md +240 -0
  156. package/tests/github-local/run-all-tests.sh +422 -0
  157. package/tests/github-local/test-job-workflows.sh +631 -0
  158. package/tests/github-local/test-pipeline-workflow.sh +440 -0
  159. package/tests/integration/generators.test.ts +578 -0
  160. package/tests/integration/path-based-template.test.ts +510 -0
  161. package/tests/integration/simple-path-based.test.ts +415 -0
  162. package/tests/setup.ts +56 -0
  163. package/tests/unit/ast-path-operations-extended.test.ts +302 -0
  164. package/tests/unit/cli.test.ts +541 -0
  165. package/tests/unit/config-extended.test.ts +412 -0
  166. package/tests/unit/config.test.ts +152 -0
  167. package/tests/unit/github-setup.test.ts +189 -0
  168. package/tests/unit/idempotency-isolated.test.ts +297 -0
  169. package/tests/unit/job-order.test.ts +157 -0
  170. package/tests/unit/pipeline-path-based.test.ts +511 -0
  171. package/tests/unit/validate-pipeline.test.ts +384 -0
  172. package/tests/unit/versioning-extended.test.ts +279 -0
  173. package/tests/unit/versioning.test.ts +241 -0
  174. package/tsconfig.debug.json +17 -0
  175. package/tsconfig.json +28 -0
  176. package/vitest.config.ts +51 -0
@@ -0,0 +1,652 @@
1
+ import { toFile, renderTemplate } from '@featherscloud/pinion';
2
+ import { parseDocument, stringify, Scalar } from 'yaml';
3
+ import fs from 'fs';
4
+ import { applyPathOperations, createValueFromString } from '../../utils/ast-path-operations.js';
5
+ import dedent from 'dedent';
6
+ /**
7
+ * Path-based pipeline generator
8
+ *
9
+ * Uses precise AST path operations to:
10
+ * - Ensure required paths exist
11
+ * - Set/merge/overwrite specific values
12
+ * - Preserve user customizations while ensuring template requirements
13
+ */
14
+ /**
15
+ * Get minimal base template - just enough structure to be parsed
16
+ * All actual content is defined via operations list
17
+ */
18
+ const getBaseTemplate = (ctx) => {
19
+ return dedent `
20
+ name: "Pipeline"
21
+ on:
22
+ jobs:
23
+ `;
24
+ };
25
+ /**
26
+ * Define which jobs Pipecraft owns vs user jobs
27
+ */
28
+ const getPipecraftOwnedJobs = (branchFlow, domains = {}) => {
29
+ const jobs = new Set([
30
+ 'changes',
31
+ 'version',
32
+ 'tag',
33
+ 'promote', // Single promote job instead of multiple promote-to-{target} jobs
34
+ 'release' // GitHub release creation on final branch
35
+ ]);
36
+ // Add domain-based jobs (test-*, deploy-*, remote-test-*) based on flags
37
+ Object.keys(domains).forEach(domain => {
38
+ const domainConfig = domains[domain];
39
+ if (domainConfig.test !== false)
40
+ jobs.add(`test-${domain}`);
41
+ if (domainConfig.deploy === true)
42
+ jobs.add(`deploy-${domain}`);
43
+ if (domainConfig.remoteTest === true)
44
+ jobs.add(`remote-test-${domain}`);
45
+ });
46
+ return jobs;
47
+ };
48
+ /**
49
+ * Check if a job is owned by Pipecraft
50
+ */
51
+ const isPipecraftJob = (jobName, branchFlow) => {
52
+ return getPipecraftOwnedJobs(branchFlow).has(jobName);
53
+ };
54
+ /**
55
+ * Create path-based pipeline content
56
+ */
57
+ export const createPathBasedPipeline = (ctx) => {
58
+ const branchFlow = ctx.branchFlow || ['develop', 'staging', 'main'];
59
+ console.log('🔍 Branch flow from context:', branchFlow);
60
+ console.log('🔍 Context keys:', Object.keys(ctx));
61
+ // Use existing pipeline from context or start with base template
62
+ let doc;
63
+ let hasExistingPipeline = false;
64
+ if (ctx.existingPipelineContent) {
65
+ // Parse the original YAML content WITHOUT source tokens
66
+ // This prevents old comments from being preserved when we rebuild
67
+ doc = parseDocument(ctx.existingPipelineContent);
68
+ hasExistingPipeline = true;
69
+ console.log('🔄 Merging with existing pipeline from existingPipelineContent');
70
+ }
71
+ else if (ctx.existingPipeline) {
72
+ // Convert existing pipeline object to YAML string first
73
+ const existingYaml = stringify(ctx.existingPipeline);
74
+ doc = parseDocument(existingYaml);
75
+ hasExistingPipeline = true;
76
+ console.log('🔄 Merging with existing pipeline from existingPipeline object');
77
+ }
78
+ else {
79
+ doc = parseDocument(getBaseTemplate(ctx));
80
+ console.log('📝 Creating new pipeline');
81
+ }
82
+ if (!doc.contents) {
83
+ throw new Error('Failed to parse pipeline document');
84
+ }
85
+ // Apply path-based operations
86
+ const operations = [
87
+ // =============================================================================
88
+ // WORKFLOW METADATA - Name and run identification
89
+ // =============================================================================
90
+ {
91
+ path: 'run-name',
92
+ operation: 'set',
93
+ value: `\${{ github.ref_name }} #\${{ inputs.run_number || github.run_number }}\${{ inputs.version && format(' - {0}', inputs.version) || '' }}`,
94
+ required: true
95
+ },
96
+ // =============================================================================
97
+ // WORKFLOW TRIGGERS - Define when the pipeline runs
98
+ // =============================================================================
99
+ // The pipeline should only run on:
100
+ // 1. Push to develop/staging/main (from PR merge or promotion)
101
+ // 2. Manual trigger via workflow_dispatch
102
+ //
103
+ // NOT on pull_request events - we only want to run after the PR is merged
104
+ {
105
+ path: 'on.workflow_dispatch.inputs.version',
106
+ operation: 'set',
107
+ value: {
108
+ description: 'The version to deploy',
109
+ required: false,
110
+ type: 'string'
111
+ },
112
+ required: true
113
+ },
114
+ {
115
+ path: 'on.workflow_dispatch.inputs.baseRef',
116
+ operation: 'set',
117
+ value: {
118
+ description: 'The base reference for comparison',
119
+ required: false,
120
+ type: 'string'
121
+ },
122
+ required: true
123
+ },
124
+ {
125
+ path: 'on.workflow_dispatch.inputs.run_number',
126
+ operation: 'set',
127
+ value: {
128
+ description: 'The original run number from develop branch',
129
+ required: false,
130
+ type: 'string'
131
+ },
132
+ required: true
133
+ },
134
+ {
135
+ path: 'on.push.branches',
136
+ operation: 'set',
137
+ value: branchFlow,
138
+ required: true
139
+ },
140
+ // =============================================================================
141
+ // CORE PIPECRAFT JOBS - Template-managed jobs that get updates
142
+ // =============================================================================
143
+ // These are the core Pipecraft jobs that should always use the latest template
144
+ // version. Using 'overwrite' operation ensures users get bug fixes and improvements.
145
+ // These jobs are essential for Pipecraft functionality and should not be customized.
146
+ {
147
+ path: 'jobs.changes',
148
+ operation: 'overwrite',
149
+ value: createValueFromString(`
150
+ runs-on: ubuntu-latest
151
+ steps:
152
+ - uses: actions/checkout@v4
153
+ - uses: ./.github/actions/detect-changes
154
+ id: detect
155
+ with:
156
+ baseRef: \${{ inputs.baseRef || '${ctx.finalBranch || "main"}' }}
157
+ outputs:
158
+ ${Object.keys(ctx.domains || {}).sort().map((domain) => ` ${domain}: \${{ steps.detect.outputs.${domain} }}`).join('\n')}
159
+ `, ctx),
160
+ commentBefore: dedent `
161
+ =============================================================================
162
+ CHANGES DETECTION
163
+ =============================================================================
164
+ `,
165
+ required: true
166
+ },
167
+ // =============================================================================
168
+ // USER-MANAGED SECTIONS - Preserve user customizations
169
+ // =============================================================================
170
+ // These sections are designed for user customizations (testing, deployment).
171
+ // Using 'preserve' operation to keep any existing user jobs while providing
172
+ // template structure and examples for new users.
173
+ // Generate test jobs for each domain (only if test: true)
174
+ ...Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].test !== false).map((domain) => ({
175
+ path: `jobs.test-${domain}`,
176
+ operation: 'preserve',
177
+ value: createValueFromString(`
178
+ needs: changes
179
+ if: \${{ needs.changes.outputs.${domain} == 'true' }}
180
+ runs-on: ubuntu-latest
181
+ steps:
182
+ # TODO: Replace with your ${domain} test logic
183
+ - name: Run ${domain} tests
184
+ run: |
185
+ echo "Running tests for ${domain} domain"
186
+ echo "Replace this with your actual test commands"
187
+ # Example: npm test -- --testPathPattern=${domain}
188
+ `, ctx),
189
+ commentBefore: domain === Object.keys(ctx.domains || {}).sort()[0] ? dedent `
190
+
191
+
192
+ =============================================================================
193
+ TESTING JOBS
194
+ =============================================================================
195
+ ` : undefined,
196
+ required: true
197
+ })),
198
+ {
199
+ path: 'jobs.version',
200
+ operation: 'overwrite',
201
+ commentBefore: dedent `
202
+ =============================================================================
203
+ VERSIONING
204
+ =============================================================================
205
+ `,
206
+ value: createValueFromString(`
207
+ if: \${{ always() && (${Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].test !== false).map((domain) => `needs.test-${domain}.result == 'success'`).join(' || ')}) && ${Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].test !== false).map((domain) => `needs.test-${domain}.result != 'failure'`).join(' && ')} }}
208
+ needs: [ changes, ${Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].test !== false).map((domain) => `test-${domain}`).join(', ')} ]
209
+ runs-on: ubuntu-latest
210
+ steps:
211
+ - uses: actions/checkout@v4
212
+ - uses: ./.github/actions/calculate-version
213
+ id: version
214
+ with:
215
+ baseRef: \${{ inputs.baseRef || '${ctx.finalBranch || "main"}' }}
216
+ outputs:
217
+ version: \${{ steps.version.outputs.version }}
218
+ `, ctx),
219
+ required: true,
220
+ spaceBefore: true,
221
+ },
222
+ // Generate deployment jobs for each domain (only if deploy: true)
223
+ ...Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].deploy === true).map((domain, index) => ({
224
+ path: `jobs.deploy-${domain}`,
225
+ operation: 'overwrite',
226
+ commentBefore: index === 0 ? dedent `
227
+ =============================================================================
228
+ DEPLOYMENT JOBS
229
+ =============================================================================
230
+ ` : undefined,
231
+ spaceBefore: index === 0 ? true : undefined,
232
+ value: createValueFromString(`
233
+ needs: [ version, changes ]
234
+ if: \${{ always() && needs.version.result == 'success' && needs.changes.outputs.${domain} == 'true' }}
235
+ runs-on: ubuntu-latest
236
+ steps:
237
+ - name: Deploy ${domain}
238
+ run: |
239
+ echo "Deploying ${domain}"
240
+ echo "Replace this with your actual deploy commands"
241
+ # Example: npm deploy -- --testPathPattern=${domain}
242
+ `, ctx),
243
+ required: true
244
+ })),
245
+ // Generate remote testing jobs for each domain (only if remoteTest: true)
246
+ ...Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].remoteTest === true).map((domain, index) => ({
247
+ path: `jobs.remote-test-${domain}`,
248
+ operation: 'overwrite',
249
+ commentBefore: index === 0 ? dedent `
250
+ =============================================================================
251
+ REMOTE TESTING JOBS
252
+ =============================================================================
253
+ ` : undefined,
254
+ spaceBefore: index === 0 ? true : undefined,
255
+ value: createValueFromString(`
256
+ needs: [ deploy-${domain}, changes ]
257
+ if: \${{ always() }}
258
+ runs-on: ubuntu-latest
259
+ steps:
260
+ - name: Test ${domain}
261
+ if: \${{ needs.changes.outputs.${domain} == 'true' && needs.deploy-${domain}.result == 'success' }}
262
+ run: |
263
+ echo "Testing ${domain} remotely"
264
+ echo "Replace this with your actual test commands"
265
+ # Example: npm test -- --testPathPattern=${domain}
266
+ `, ctx),
267
+ required: true
268
+ })),
269
+ {
270
+ path: 'jobs.tag',
271
+ operation: 'overwrite',
272
+ value: (() => {
273
+ // Build list of deploy and remote-test jobs that tag depends on
274
+ const deployJobs = Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].deploy === true).map((domain) => `deploy-${domain}`);
275
+ const remoteTestJobs = Object.keys(ctx.domains || {}).sort().filter((domain) => ctx.domains[domain].remoteTest === true).map((domain) => `remote-test-${domain}`);
276
+ const allDeployTestJobs = [...deployJobs, ...remoteTestJobs];
277
+ // Build needs array (version + all deploy/remote-test jobs)
278
+ const needsArray = ['version', ...allDeployTestJobs];
279
+ // Build conditional: no failures AND at least one success
280
+ const noFailures = allDeployTestJobs.length > 0
281
+ ? allDeployTestJobs.map((job) => `needs.${job}.result != 'failure'`).join(' && \n ')
282
+ : 'true';
283
+ const atLeastOneSuccess = allDeployTestJobs.length > 0
284
+ ? allDeployTestJobs.map((job) => `needs.${job}.result == 'success'`).join(' || \n ')
285
+ : 'true';
286
+ return createValueFromString(`
287
+ # Needs all deploy and/or remote test jobs to succeed or be skipped
288
+ # Needs at least one domain to succeed
289
+ needs: [ ${needsArray.join(', ')} ]
290
+ if: \${{
291
+ always() &&
292
+ github.ref_name == '${ctx.initialBranch || branchFlow[0]}' &&
293
+ needs.version.result == 'success' &&
294
+ (
295
+ ${noFailures}
296
+ ) &&
297
+ (
298
+ ${atLeastOneSuccess}
299
+ )
300
+ }}
301
+ runs-on: ubuntu-latest
302
+ steps:
303
+ - uses: actions/checkout@v4
304
+ - uses: ./.github/actions/create-tag
305
+ with:
306
+ version: \${{ needs.version.outputs.version }}
307
+ `, ctx);
308
+ })(),
309
+ spaceBefore: true,
310
+ commentBefore: dedent `
311
+ =============================================================================
312
+ TAG & PROMOTE
313
+ =============================================================================
314
+ `,
315
+ required: true
316
+ },
317
+ // Generate single promotion job that handles all branch transitions dynamically
318
+ {
319
+ path: 'jobs.promote',
320
+ operation: 'overwrite',
321
+ value: createValueFromString(`
322
+ # Only runs on push or manual workflow_dispatch events to branches that can promote
323
+ # Waits for version/tag if they run, but doesn't fail if they're skipped
324
+ # Needs all deploy and/or remote test jobs to succeed
325
+ if: \${{
326
+ always() &&
327
+ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
328
+ (needs.version.result == 'success' || needs.version.result == 'skipped') &&
329
+ (needs.tag.result == 'success' || needs.tag.result == 'skipped') &&
330
+ (
331
+ ${branchFlow.slice(0, -1).map((branch) => `github.ref_name == '${branch}'`).join(' || \n ')}
332
+ )
333
+ }}
334
+ needs: [ version, tag ]
335
+ runs-on: ubuntu-latest
336
+ steps:
337
+ - uses: actions/checkout@v4
338
+ - uses: ./.github/actions/promote-branch
339
+ with:
340
+ sourceBranch: \${{ github.ref_name }}
341
+ version: \${{ needs.version.outputs.version }}
342
+ run_number: \${{ inputs.run_number || github.run_number }}
343
+ token: \${{ secrets.GITHUB_TOKEN }}
344
+ `, ctx),
345
+ spaceBefore: true,
346
+ commentBefore: dedent `
347
+ =============================================================================
348
+ PROMOTION JOB
349
+ =============================================================================
350
+ `,
351
+ required: true
352
+ },
353
+ // Generate release job for final branch (main)
354
+ {
355
+ path: 'jobs.release',
356
+ operation: 'overwrite',
357
+ value: createValueFromString(`
358
+ # Create GitHub release on main branch after successful tests and versioning
359
+ if: \${{
360
+ always() &&
361
+ github.ref_name == '${ctx.finalBranch || branchFlow[branchFlow.length - 1]}' &&
362
+ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
363
+ needs.version.result == 'success' &&
364
+ (needs.tag.result == 'success' || needs.tag.result == 'skipped')
365
+ }}
366
+ needs: [ version, tag ]
367
+ runs-on: ubuntu-latest
368
+ steps:
369
+ - uses: actions/checkout@v4
370
+ - uses: ./.github/actions/create-release
371
+ with:
372
+ version: \${{ needs.version.outputs.version }}
373
+ token: \${{ secrets.GITHUB_TOKEN }}
374
+ `, ctx),
375
+ spaceBefore: true,
376
+ commentBefore: dedent `
377
+ =============================================================================
378
+ RELEASE JOB (Main Branch Only)
379
+ =============================================================================
380
+ `,
381
+ required: true
382
+ },
383
+ ];
384
+ // Helper function to get job keys in order
385
+ const getJobKeysInOrder = (jobsNode) => {
386
+ if (!jobsNode || !jobsNode.items)
387
+ return [];
388
+ return jobsNode.items
389
+ .filter((item) => item.key)
390
+ .map((item) => item.key.toString());
391
+ };
392
+ // Unified approach: Use the operation system for all Pipecraft jobs
393
+ // The operation system already handles:
394
+ // - If key exists and is owned by Pipecraft → overwrite it
395
+ // - If key doesn't exist → create it
396
+ // - If key isn't part of Pipecraft → ignore it (via job filtering)
397
+ const PIPECRAFT_OWNED_JOBS = getPipecraftOwnedJobs(branchFlow, ctx.domains);
398
+ // Capture original job order before any modifications
399
+ let originalJobOrder = [];
400
+ if (doc.contents.get('jobs')) {
401
+ const jobsNode = doc.contents.get('jobs');
402
+ if (jobsNode && jobsNode.items) {
403
+ originalJobOrder = getJobKeysInOrder(jobsNode);
404
+ console.log('📋 Original job order:', originalJobOrder);
405
+ }
406
+ }
407
+ // Collect user jobs (non-Pipecraft jobs) to preserve them
408
+ const userJobs = new Map();
409
+ if (doc.contents.get('jobs')) {
410
+ const jobsNode = doc.contents.get('jobs');
411
+ if (jobsNode && jobsNode.items) {
412
+ for (const item of jobsNode.items) {
413
+ const jobName = item.key?.toString() || item.key?.value;
414
+ if (jobName && !PIPECRAFT_OWNED_JOBS.has(jobName)) {
415
+ userJobs.set(jobName, item.value);
416
+ }
417
+ }
418
+ if (userJobs.size > 0) {
419
+ console.log(`📋 Preserving ${userJobs.size} user jobs: ${Array.from(userJobs.keys()).join(', ')}`);
420
+ }
421
+ }
422
+ }
423
+ // Clear the entire jobs section to rebuild in correct order
424
+ const jobsNode = doc.contents.get('jobs');
425
+ if (jobsNode && jobsNode.items) {
426
+ jobsNode.items = [];
427
+ // Clear any orphaned comments that were attached to the jobs node
428
+ // When we parse YAML with comments and clear items, comments can become orphaned on the parent
429
+ delete jobsNode.commentBefore;
430
+ delete jobsNode.comment;
431
+ }
432
+ // Apply all operations in order - this creates/overwrites Pipecraft jobs
433
+ // The 'overwrite' operation handles both create and update cases automatically
434
+ applyPathOperations(doc.contents, operations, doc);
435
+ // Remove old trigger types that are no longer used (workflow_call, pull_request)
436
+ // We only want push and workflow_dispatch triggers
437
+ const onNode = doc.contents.get('on');
438
+ if (onNode && onNode.delete) {
439
+ onNode.delete('workflow_call');
440
+ onNode.delete('pull_request');
441
+ }
442
+ // Now we need to reorder jobs to match the original order
443
+ // Collect all current jobs (Pipecraft jobs that were just created)
444
+ const currentJobs = new Map();
445
+ if (jobsNode && jobsNode.items) {
446
+ for (const item of jobsNode.items) {
447
+ const jobName = item.key?.toString();
448
+ if (jobName) {
449
+ currentJobs.set(jobName, item);
450
+ }
451
+ }
452
+ }
453
+ // Clear again to rebuild in correct order
454
+ if (jobsNode && jobsNode.items) {
455
+ jobsNode.items = [];
456
+ }
457
+ // Rebuild jobs in original order
458
+ // For each job in the original order:
459
+ // - If it's a Pipecraft job, use the newly created version from currentJobs
460
+ // - If it's a user job, use the preserved version from userJobs
461
+ // IMPORTANT: Ensure all keys are Scalars (not strings) so we can add comments later
462
+ for (const jobName of originalJobOrder) {
463
+ if (PIPECRAFT_OWNED_JOBS.has(jobName)) {
464
+ // It's a Pipecraft job - use the newly created version
465
+ const item = currentJobs.get(jobName);
466
+ if (item && jobsNode) {
467
+ // Ensure the key is a Scalar, not a string
468
+ if (typeof item.key === 'string') {
469
+ item.key = new Scalar(item.key);
470
+ }
471
+ jobsNode.items.push(item);
472
+ }
473
+ }
474
+ else {
475
+ // It's a user job - re-insert from preserved values
476
+ const jobValue = userJobs.get(jobName);
477
+ if (jobValue && jobsNode) {
478
+ jobsNode.set(jobName, jobValue);
479
+ }
480
+ }
481
+ }
482
+ // Add any new Pipecraft jobs that weren't in the original order (at the end)
483
+ for (const [jobName, item] of currentJobs) {
484
+ if (!originalJobOrder.includes(jobName) && jobsNode) {
485
+ // Ensure the key is a Scalar, not a string
486
+ if (typeof item.key === 'string') {
487
+ item.key = new Scalar(item.key);
488
+ }
489
+ jobsNode.items.push(item);
490
+ }
491
+ }
492
+ // Log final job order (after operations are applied)
493
+ const finalJobsNode = doc.contents.get('jobs');
494
+ if (finalJobsNode && finalJobsNode.items && finalJobsNode.items.length > 0) {
495
+ const jobNames = getJobKeysInOrder(finalJobsNode);
496
+ if (jobNames.length > 0) {
497
+ console.log('📋 Final job order:', jobNames);
498
+ }
499
+ // Remove duplicate comment headers
500
+ // When we reuse existing job values, they may carry old comments from parsing
501
+ // We want to keep only the comments we explicitly set on keys
502
+ for (const item of finalJobsNode.items) {
503
+ if (item.value && item.value.commentBefore) {
504
+ // Clear commentBefore from values - comments should only be on keys
505
+ delete item.value.commentBefore;
506
+ }
507
+ }
508
+ // Add section headers and spacing to domain-based jobs
509
+ // This ensures headers appear in the right place and jobs have proper spacing
510
+ const domainKeys = Object.keys(ctx.domains || {});
511
+ if (domainKeys.length > 0) {
512
+ let foundFirstTest = false;
513
+ let foundFirstDeploy = false;
514
+ let foundFirstRemoteTest = false;
515
+ for (const item of finalJobsNode.items) {
516
+ const jobName = item.key?.toString();
517
+ if (!jobName)
518
+ continue;
519
+ // Skip if key is a string (can't add properties to primitive strings)
520
+ if (typeof item.key === 'string')
521
+ continue;
522
+ // Handle test-* jobs
523
+ if (jobName.startsWith('test-')) {
524
+ if (!foundFirstTest) {
525
+ // First test job gets the header
526
+ item.key.commentBefore = `
527
+
528
+
529
+ =============================================================================
530
+ TESTING JOBS
531
+ =============================================================================`;
532
+ item.key.spaceBefore = true;
533
+ foundFirstTest = true;
534
+ }
535
+ else {
536
+ // Subsequent test jobs get a blank line
537
+ ;
538
+ item.key.spaceBefore = true;
539
+ }
540
+ }
541
+ // Handle deploy-* jobs
542
+ if (jobName.startsWith('deploy-')) {
543
+ if (!foundFirstDeploy) {
544
+ // First deploy job gets the header
545
+ ;
546
+ item.key.commentBefore = `
547
+
548
+
549
+ =============================================================================
550
+ DEPLOYMENT JOBS
551
+ =============================================================================`;
552
+ item.key.spaceBefore = true;
553
+ foundFirstDeploy = true;
554
+ }
555
+ else {
556
+ // Subsequent deploy jobs get a blank line
557
+ ;
558
+ item.key.spaceBefore = true;
559
+ }
560
+ }
561
+ // Handle remote-test-* jobs
562
+ if (jobName.startsWith('remote-test-')) {
563
+ if (!foundFirstRemoteTest) {
564
+ // First remote-test job gets the header
565
+ ;
566
+ item.key.commentBefore = `
567
+
568
+
569
+ =============================================================================
570
+ REMOTE TESTING JOBS
571
+ =============================================================================`;
572
+ item.key.spaceBefore = true;
573
+ foundFirstRemoteTest = true;
574
+ }
575
+ else {
576
+ // Subsequent remote-test jobs get a blank line
577
+ ;
578
+ item.key.spaceBefore = true;
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+ // Generate final content with comment preservation
585
+ // Use lineWidth: 0 to prevent line wrapping of long expressions
586
+ // This keeps GitHub Actions expressions on a single line
587
+ let finalContent = stringify(doc, {
588
+ lineWidth: 0,
589
+ minContentWidth: 0
590
+ });
591
+ // Post-process: Format long GitHub Actions conditionals for better readability
592
+ // The YAML library doesn't handle newlines well in flow scalars, so we format after stringify
593
+ finalContent = finalContent.replace(/if: \$\{\{([^}]+)\}\}/g, (match, condition) => {
594
+ // Only format if the condition is long enough to benefit from formatting
595
+ if (condition.length < 100)
596
+ return match;
597
+ let formatted = condition.trim();
598
+ // Step 1: Protect function calls like always() by replacing with placeholders
599
+ const functionCalls = [];
600
+ formatted = formatted.replace(/(\w+)\(\)/g, (match) => {
601
+ const placeholder = `__FUNC_${functionCalls.length}__`;
602
+ functionCalls.push(match);
603
+ return placeholder;
604
+ });
605
+ // Step 2: Add line breaks for logical operators
606
+ formatted = formatted.replace(/\s+&&\s+/g, ' &&\n ');
607
+ formatted = formatted.replace(/\s+\|\|\s+/g, ' ||\n ');
608
+ // Step 3: Format grouping parentheses (now that function calls are protected)
609
+ formatted = formatted.replace(/\(\s*/g, '(\n ');
610
+ formatted = formatted.replace(/\s*\)\s*(&&|\|\|)/g, '\n ) $1');
611
+ formatted = formatted.replace(/\s*\)(\s*)$/g, '\n )');
612
+ // Step 4: Restore function calls
613
+ functionCalls.forEach((funcCall, index) => {
614
+ formatted = formatted.replace(`__FUNC_${index}__`, funcCall);
615
+ });
616
+ return `if: $\{{\n ${formatted}\n }}`;
617
+ });
618
+ return {
619
+ yamlContent: finalContent,
620
+ mergeStatus: hasExistingPipeline ? 'merged' : 'overwritten'
621
+ };
622
+ };
623
+ /**
624
+ * Load existing pipeline file
625
+ */
626
+ const loadExistingPipeline = (filePath) => {
627
+ if (!fs.existsSync(filePath)) {
628
+ return null;
629
+ }
630
+ return fs.readFileSync(filePath, 'utf8');
631
+ };
632
+ /**
633
+ * Main export - path-based pipeline generator
634
+ */
635
+ export const generate = (ctx) => Promise.resolve(ctx)
636
+ .then((ctx) => {
637
+ const result = createPathBasedPipeline(ctx);
638
+ return {
639
+ ...ctx,
640
+ yamlContent: result.yamlContent,
641
+ mergeStatus: result.mergeStatus
642
+ };
643
+ })
644
+ .then((ctx) => {
645
+ // Provide user feedback about file operation
646
+ const outputPath = ctx.outputPipelinePath || '.github/workflows/pipeline.yml';
647
+ const status = ctx.mergeStatus === 'merged' ? '🔄 Merged with existing' : '📝 Created new';
648
+ console.log(`${status} ${outputPath}`);
649
+ return ctx;
650
+ })
651
+ .then(renderTemplate((ctx) => ctx.yamlContent, toFile((ctx) => ctx.outputPipelinePath || '.github/workflows/pipeline.yml')));
652
+ //# sourceMappingURL=pipeline-path-based.yml.tpl.js.map