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.
- package/README.md +5 -2
- package/dist/cli/index.js +1 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/init.tpl.d.ts.map +1 -1
- package/dist/generators/init.tpl.js +63 -12
- package/dist/generators/init.tpl.js.map +1 -1
- package/dist/generators/workflows.tpl.d.ts.map +1 -1
- package/dist/generators/workflows.tpl.js +10 -3
- package/dist/generators/workflows.tpl.js.map +1 -1
- package/dist/templates/workflows/pipeline-nx.yml.tpl.d.ts +22 -0
- package/dist/templates/workflows/pipeline-nx.yml.tpl.d.ts.map +1 -0
- package/dist/templates/workflows/pipeline-nx.yml.tpl.js +179 -0
- package/dist/templates/workflows/pipeline-nx.yml.tpl.js.map +1 -0
- package/dist/templates/workflows/pipeline.yml.tpl.d.ts +22 -0
- package/dist/templates/workflows/pipeline.yml.tpl.d.ts.map +1 -0
- package/dist/templates/workflows/pipeline.yml.tpl.js +136 -0
- package/dist/templates/workflows/pipeline.yml.tpl.js.map +1 -0
- package/dist/templates/workflows/shared/index.d.ts +11 -0
- package/dist/templates/workflows/shared/index.d.ts.map +1 -0
- package/dist/templates/workflows/shared/index.js +11 -0
- package/dist/templates/workflows/shared/index.js.map +1 -0
- package/dist/templates/workflows/shared/operations-changes.d.ts +17 -0
- package/dist/templates/workflows/shared/operations-changes.d.ts.map +1 -0
- package/dist/templates/workflows/shared/operations-changes.js +49 -0
- package/dist/templates/workflows/shared/operations-changes.js.map +1 -0
- package/dist/templates/workflows/shared/operations-domain-jobs.d.ts +31 -0
- package/dist/templates/workflows/shared/operations-domain-jobs.d.ts.map +1 -0
- package/dist/templates/workflows/shared/operations-domain-jobs.js +139 -0
- package/dist/templates/workflows/shared/operations-domain-jobs.js.map +1 -0
- package/dist/templates/workflows/shared/operations-header.d.ts +15 -0
- package/dist/templates/workflows/shared/operations-header.d.ts.map +1 -0
- package/dist/templates/workflows/shared/operations-header.js +158 -0
- package/dist/templates/workflows/shared/operations-header.js.map +1 -0
- package/dist/templates/workflows/shared/operations-tag-promote.d.ts +16 -0
- package/dist/templates/workflows/shared/operations-tag-promote.d.ts.map +1 -0
- package/dist/templates/workflows/shared/operations-tag-promote.js +115 -0
- package/dist/templates/workflows/shared/operations-tag-promote.js.map +1 -0
- package/dist/templates/workflows/shared/operations-version.d.ts +16 -0
- package/dist/templates/workflows/shared/operations-version.d.ts.map +1 -0
- package/dist/templates/workflows/shared/operations-version.js +57 -0
- package/dist/templates/workflows/shared/operations-version.js.map +1 -0
- package/dist/templates/yaml-format-utils.d.ts +24 -0
- package/dist/templates/yaml-format-utils.d.ts.map +1 -0
- package/dist/templates/yaml-format-utils.js +50 -0
- package/dist/templates/yaml-format-utils.js.map +1 -0
- package/dist/types/index.d.ts +42 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/generators/init.tpl.ts +71 -15
- package/src/generators/workflows.tpl.ts +9 -3
- package/dist/templates/workflows/pipeline-path-based.yml.tpl.d.ts +0 -129
- package/dist/templates/workflows/pipeline-path-based.yml.tpl.d.ts.map +0 -1
- package/dist/templates/workflows/pipeline-path-based.yml.tpl.js +0 -1057
- package/dist/templates/workflows/pipeline-path-based.yml.tpl.js.map +0 -1
- package/dist/utils/github-setup-v2.d.ts +0 -15
- package/dist/utils/github-setup-v2.d.ts.map +0 -1
- package/dist/utils/github-setup-v2.js +0 -217
- 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
|