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