enablement-build-template-generate 1.0.20 → 1.3.1
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 +29 -33
- package/package.json +9 -8
- package/src/index.mjs +182 -67
- package/src/lib.mjs +131 -0
- package/tests/index.test.mjs +362 -2
- package/tests/lib.test.mjs +208 -0
package/README.md
CHANGED
|
@@ -12,11 +12,13 @@ npx enablement-build-template-generate@latest [flags]
|
|
|
12
12
|
|
|
13
13
|
## How it works
|
|
14
14
|
|
|
15
|
-
1. **
|
|
16
|
-
2. **Read config** — If `manifest.json`
|
|
17
|
-
3. **
|
|
18
|
-
4. **
|
|
19
|
-
5. **
|
|
15
|
+
1. **Select platform** — Uses Azure DevOps (`.cicd/`) by default. Pass `--platform github` to use GitHub (`.github/`).
|
|
16
|
+
2. **Read config** — `{configDir}/templates/templated.json` is the source of truth. If `manifest.json` still has a `templated` section (from older tooling), it's read once, merged into `templated.json`, and removed from `manifest.json`. A `templated.json` in the legacy `{ rootPath, template }` shape is converted to the current format (packages discovered by globbing `rootPath`).
|
|
17
|
+
3. **Detect new packages** — Any package not yet assigned a template (scanned via `--children`/auto-detected workspace roots) is classified by type (node/python/dotnet/docker) and assigned a template, reusing the convention of other packages in the same folder or of the same type where possible.
|
|
18
|
+
4. **Persist** — `templated.json` (and `manifest.json`, if it needed migrating) is written back, only when something actually changed.
|
|
19
|
+
5. **Expand job templates** — For each package, reads its job snippet template and fills in placeholders (`folderName`/`packageFolder`/`metadata` come from `templated.json`'s own `cicd` section). Packages are ordered so that, per `manifest.json`'s `dependencies` graph (when present), a package's local dependencies are generated before it, as much as possible.
|
|
20
|
+
6. **Stitch** — Joins all expanded job snippets and substitutes them into the document template at the named placeholder.
|
|
21
|
+
7. **Write output** — Writes the final file one level above the template directory (`.github/templates/` → `.github/` or `.cicd/templates/` → `.cicd/`), wrapped in `DO NOT EDIT` banners.
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
@@ -24,22 +26,22 @@ npx enablement-build-template-generate@latest [flags]
|
|
|
24
26
|
|
|
25
27
|
| Flag | Default | Description |
|
|
26
28
|
|---|---|---|
|
|
27
|
-
| `--platform <name>` |
|
|
28
|
-
| `--
|
|
29
|
+
| `--platform <name>` | `ado` | Select the platform: `ado` (Azure DevOps) or `github` (GitHub Actions). |
|
|
30
|
+
| `--children <dirs>` | auto-detect | Comma- or space-separated list of top-level directories to scan when detecting new (not-yet-configured) packages. When omitted, directories are read from `pnpm-workspace.yaml` or `package.json` workspaces automatically. |
|
|
31
|
+
| `--exclude <list>` | — | Comma- or space-separated list of names to skip: package folder names during generation, and top-level directories (from `--children` or auto-detection) during new-package scanning. |
|
|
29
32
|
| `--debug` | off | Print verbose output. |
|
|
30
33
|
|
|
31
34
|
---
|
|
32
35
|
|
|
33
36
|
## Config source
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
### `templated.json` (current format)
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Reads the `templated` section of `{configDir}/manifest.json` (produced by `enablement-build-manifest`). Each key is the **output filename**; each value maps a placeholder variable name to a per-package template mapping.
|
|
40
|
+
`{configDir}/templates/templated.json` is the source of truth for template routing. Each key under `templated` is the **output filename**; each value maps a placeholder variable name to a per-package template mapping. `cicd` carries the per-package metadata generation needs.
|
|
40
41
|
|
|
41
42
|
```json
|
|
42
43
|
{
|
|
44
|
+
"version": 2,
|
|
43
45
|
"templated": {
|
|
44
46
|
"build.yml": {
|
|
45
47
|
"PACKAGES": {
|
|
@@ -48,30 +50,24 @@ Reads the `templated` section of `{configDir}/manifest.json` (produced by `enabl
|
|
|
48
50
|
"my-scope/python-lib": ["build_python_package.yml", "build_scan.yml"]
|
|
49
51
|
}
|
|
50
52
|
}
|
|
53
|
+
},
|
|
54
|
+
"cicd": {
|
|
55
|
+
"my-scope/component-a": { "folderName": "component-a", "packageFolder": "packages" }
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
Each package name maps to a **single template filename** or an **array of template filenames** (all expanded in order for that package). Template lookup tries the active platform templates directory first (`.cicd/templates/` or `.github/templates/`) and then falls back to the other platform directory if a file is missing.
|
|
56
61
|
|
|
57
|
-
Package metadata (`folderName`, `packageFolder`) is read from `
|
|
62
|
+
Package metadata (`folderName`, `packageFolder`, `metadata`) is read from `templated.json`'s own `cicd` map and used to fill template placeholders.
|
|
58
63
|
|
|
59
|
-
|
|
64
|
+
A `templated.json` in the legacy `{ rootPath, template }` shape (no top-level `templated`/`version` key) is converted to the current format automatically — packages are discovered by globbing `rootPath` for `package.json`/`version.json`/`pyproject.toml`, and the result is written back.
|
|
60
65
|
|
|
61
|
-
|
|
66
|
+
### `manifest.json` (one-time migration source + dependency graph)
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
{
|
|
65
|
-
"build.yml": {
|
|
66
|
-
"PACKAGES": {
|
|
67
|
-
"rootPath": "../../packages",
|
|
68
|
-
"template": "build_node_component.yml"
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
68
|
+
If `{configDir}/manifest.json` still has a `templated` section (from older tooling, or `enablement-build-manifest` before it stopped writing one), it's merged into `templated.json` and removed from `manifest.json` — existing assignments in `templated.json` win over stale ones from `manifest.json`.
|
|
73
69
|
|
|
74
|
-
|
|
70
|
+
Independently of that migration, when `manifest.json` exists its `dependencies` map (`{ "<package>": ["<local-dep>", ...] }`) is used to order generation: within each placeholder, a package's local dependencies are generated before it, as much as possible (dependencies outside that placeholder's package list, or on a package that also depends back on it, are simply skipped rather than causing an error).
|
|
75
71
|
|
|
76
72
|
---
|
|
77
73
|
|
|
@@ -101,9 +97,9 @@ Each job snippet template is expanded once per package. The following placeholde
|
|
|
101
97
|
| `{full}` | Full npm package name including scope (e.g. `@scope/my-component`) |
|
|
102
98
|
| `{path}` | The package folder root (from `cicd[pkg].packageFolder`, e.g. `packages`) |
|
|
103
99
|
| `{other}` | Expands to a comma-separated list of paths to every **other** package in the same variable — useful for pipeline dependency declarations |
|
|
104
|
-
| `{metadataKey}` | Custom placeholders like `{owner}` or `{tier}` are replaced from `
|
|
100
|
+
| `{metadataKey}` | Custom placeholders like `{owner}` or `{tier}` are replaced from `templated.json`'s `cicd[packageName].metadata` |
|
|
105
101
|
|
|
106
|
-
|
|
102
|
+
Custom metadata placeholders aren't supported for a package still being discovered via the legacy `{ rootPath, template }` conversion (they remain unchanged in output) — once converted, `templated.json`'s `cicd` map can carry `metadata` for any package.
|
|
107
103
|
|
|
108
104
|
---
|
|
109
105
|
|
|
@@ -111,25 +107,25 @@ In `templated.json` legacy mode, custom metadata placeholders are not supported
|
|
|
111
107
|
|
|
112
108
|
Job template files live in the templates subfolder; document templates and generated output files are one level up.
|
|
113
109
|
|
|
114
|
-
**Azure DevOps
|
|
110
|
+
**Azure DevOps**:
|
|
115
111
|
```
|
|
116
112
|
.cicd/
|
|
117
|
-
├── manifest.json ←
|
|
113
|
+
├── manifest.json ← optional: dependency graph for ordering, one-time templated migration source
|
|
118
114
|
├── build.yml ← generated output
|
|
119
115
|
└── templates/
|
|
120
116
|
├── build.yml ← document template
|
|
121
|
-
├── templated.json ←
|
|
117
|
+
├── templated.json ← source of truth for templated config
|
|
122
118
|
└── build_node_component.yml ← job snippet template
|
|
123
119
|
```
|
|
124
120
|
|
|
125
|
-
**GitHub Actions
|
|
121
|
+
**GitHub Actions**:
|
|
126
122
|
```
|
|
127
123
|
.github/
|
|
128
|
-
├── manifest.json ←
|
|
124
|
+
├── manifest.json ← optional: dependency graph for ordering, one-time templated migration source
|
|
129
125
|
├── build.yml ← generated output
|
|
130
126
|
└── templates/
|
|
131
127
|
├── build.yml ← document template
|
|
132
|
-
├── templated.json ←
|
|
128
|
+
├── templated.json ← source of truth for templated config
|
|
133
129
|
└── build_node_component.yml ← job snippet template
|
|
134
130
|
```
|
|
135
131
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enablement-build-template-generate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Generates build yaml for mono repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./src/index.mjs",
|
|
@@ -8,16 +8,12 @@
|
|
|
8
8
|
"bin": "./src/index.mjs",
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"globby": "^16.2.0",
|
|
11
|
-
"user": "^0.0.0"
|
|
11
|
+
"user": "^0.0.0",
|
|
12
|
+
"enablement-build-manifest": "1.4.0"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
15
|
"jest": "^30.4.2"
|
|
15
16
|
},
|
|
16
|
-
"scripts": {
|
|
17
|
-
"start": "node src/index.mjs",
|
|
18
|
-
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
19
|
-
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
|
|
20
|
-
},
|
|
21
17
|
"jest": {
|
|
22
18
|
"testEnvironment": "node",
|
|
23
19
|
"testMatch": [
|
|
@@ -25,5 +21,10 @@
|
|
|
25
21
|
]
|
|
26
22
|
},
|
|
27
23
|
"license": "MIT",
|
|
28
|
-
"author": "Contributors"
|
|
24
|
+
"author": "Contributors",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"start": "node src/index.mjs",
|
|
27
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
28
|
+
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
|
|
29
|
+
}
|
|
29
30
|
}
|
package/src/index.mjs
CHANGED
|
@@ -15,6 +15,17 @@ import {
|
|
|
15
15
|
resolveOtherPlaceholders,
|
|
16
16
|
applyVarsToDocTemplate,
|
|
17
17
|
wrapWithBanner,
|
|
18
|
+
resolveWorkspaceChildren,
|
|
19
|
+
excludeChildren,
|
|
20
|
+
buildPackageJsonGlob,
|
|
21
|
+
detectCicdType,
|
|
22
|
+
isAdvancedTemplatedFormat,
|
|
23
|
+
TEMPLATED_JSON_ADVANCED_VERSION,
|
|
24
|
+
extractGenerationCicd,
|
|
25
|
+
collectAssignedPackages,
|
|
26
|
+
listBuildTemplateFiles,
|
|
27
|
+
chooseAssignmentForNewPackage,
|
|
28
|
+
topologicalSort,
|
|
18
29
|
} from './lib.mjs';
|
|
19
30
|
|
|
20
31
|
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -67,14 +78,19 @@ function resolveFolderName(pkgName, info = {}) {
|
|
|
67
78
|
// ---------------------------------------------------------------------------
|
|
68
79
|
// Expand using manifest.json "templated" section (new format)
|
|
69
80
|
// ---------------------------------------------------------------------------
|
|
70
|
-
function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options) {
|
|
81
|
+
function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallbackTemplatePath, options, dependencies = {}) {
|
|
71
82
|
return new Promise((resolve, reject) => {
|
|
72
83
|
try {
|
|
73
|
-
const
|
|
84
|
+
const filteredNames = Object.keys(packageMap).filter(pkg => {
|
|
74
85
|
if (packageMap[pkg] === false) return false;
|
|
86
|
+
const templateFiles = Array.isArray(packageMap[pkg]) ? packageMap[pkg] : [packageMap[pkg]];
|
|
87
|
+
if (templateFiles.every(f => !f)) return false;
|
|
75
88
|
const folderName = resolveFolderName(pkg, cicd[pkg] || {});
|
|
76
89
|
return !options.exclude.includes(folderName);
|
|
77
90
|
});
|
|
91
|
+
// Order so a package's local dependencies are generated before it,
|
|
92
|
+
// as much as possible (per manifest.json's "dependencies").
|
|
93
|
+
const packageNames = topologicalSort(filteredNames, dependencies);
|
|
78
94
|
|
|
79
95
|
const allFolderNames = packageNames.map(pkg => resolveFolderName(pkg, cicd[pkg] || {}));
|
|
80
96
|
|
|
@@ -113,86 +129,198 @@ function expandVarFromManifest(varname, packageMap, cicd, templatePath, fallback
|
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
// ---------------------------------------------------------------------------
|
|
116
|
-
//
|
|
132
|
+
// Convert one legacy templated.json entry ({ rootPath, template }) into the
|
|
133
|
+
// advanced format: discover the packages currently under rootPath and assign
|
|
134
|
+
// each of them the entry's template, recording their folder/packageFolder.
|
|
117
135
|
// ---------------------------------------------------------------------------
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
});
|
|
136
|
+
async function convertLegacyEntry(jobConfig, templatePath, options) {
|
|
137
|
+
const cwd = path.join(templatePath, jobConfig.rootPath);
|
|
138
|
+
const search = buildSearchGlobs(options.exclude);
|
|
139
|
+
const files = await globby(search, { cwd });
|
|
140
|
+
|
|
141
|
+
const folderBest = groupByFolder(files);
|
|
142
|
+
const allNames = Object.keys(folderBest).sort().filter(n => !options.exclude.includes(n));
|
|
143
|
+
const packageFolder = path.relative(process.cwd(), cwd);
|
|
144
|
+
|
|
145
|
+
const pkgMap = {};
|
|
146
|
+
const cicdEntries = {};
|
|
147
|
+
allNames.forEach(name => {
|
|
148
|
+
const content = readFileSync(path.join(cwd, folderBest[name].filePath), 'utf8');
|
|
149
|
+
const ext = path.extname(folderBest[name].filePath).substring(1).toLowerCase();
|
|
150
|
+
const fullName = parseManifestName(content, ext) || name;
|
|
151
|
+
pkgMap[fullName] = jobConfig.template;
|
|
152
|
+
cicdEntries[fullName] = { folderName: name, packageFolder };
|
|
153
|
+
});
|
|
137
154
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
return { pkgMap, cicdEntries };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Convert a whole legacy-format templated.json document into the advanced
|
|
159
|
+
// { templated, cicd } shape.
|
|
160
|
+
async function convertLegacyTemplatedJson(rawConfig, templatePath, options) {
|
|
161
|
+
const templated = {};
|
|
162
|
+
const cicd = {};
|
|
163
|
+
|
|
164
|
+
for (const pipelineFile of Object.keys(rawConfig)) {
|
|
165
|
+
templated[pipelineFile] = {};
|
|
166
|
+
for (const varname of Object.keys(rawConfig[pipelineFile])) {
|
|
167
|
+
const { pkgMap, cicdEntries } = await convertLegacyEntry(rawConfig[pipelineFile][varname], templatePath, options);
|
|
168
|
+
templated[pipelineFile][varname] = pkgMap;
|
|
169
|
+
Object.assign(cicd, cicdEntries);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { templated, cicd };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Discover packages not yet assigned to any template and add them, using
|
|
178
|
+
// detected package type + the existing convention (same packageFolder, else
|
|
179
|
+
// same output type, else the default "PACKAGES" placeholder). Mutates
|
|
180
|
+
// `templated`/`cicd` in place; returns true if anything was added.
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
async function detectAndAssignNewPackages(templated, cicd, options, templatePath) {
|
|
183
|
+
const assigned = collectAssignedPackages(templated);
|
|
184
|
+
const buildTemplateFiles = listBuildTemplateFiles(templatePath);
|
|
185
|
+
|
|
186
|
+
const pkgPaths = await globby(buildPackageJsonGlob(options.children), {
|
|
187
|
+
cwd: process.cwd(),
|
|
188
|
+
ignore: ['**/node_modules/**', '**/tests/**'],
|
|
189
|
+
absolute: true,
|
|
141
190
|
});
|
|
191
|
+
|
|
192
|
+
let added = false;
|
|
193
|
+
|
|
194
|
+
pkgPaths.forEach(pkgPath => {
|
|
195
|
+
let pkg;
|
|
196
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); } catch { return; }
|
|
197
|
+
if (!pkg.name || !pkg.version || assigned.has(pkg.name)) return;
|
|
198
|
+
|
|
199
|
+
const folderName = path.basename(path.dirname(pkgPath));
|
|
200
|
+
if (options.exclude.includes(folderName)) return;
|
|
201
|
+
|
|
202
|
+
const packageFolder = path.basename(path.dirname(path.dirname(pkgPath)));
|
|
203
|
+
const detected = detectCicdType(pkg.name, pkg, pkgPath);
|
|
204
|
+
const { pipelineFile, placeholder, template } = chooseAssignmentForNewPackage({
|
|
205
|
+
packageFolder,
|
|
206
|
+
output: detected ? detected.output : undefined,
|
|
207
|
+
templated,
|
|
208
|
+
cicd,
|
|
209
|
+
buildTemplateFiles,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!template) return;
|
|
213
|
+
|
|
214
|
+
templated[pipelineFile] = templated[pipelineFile] || {};
|
|
215
|
+
templated[pipelineFile][placeholder] = templated[pipelineFile][placeholder] || {};
|
|
216
|
+
templated[pipelineFile][placeholder][pkg.name] = template;
|
|
217
|
+
cicd[pkg.name] = { ...(cicd[pkg.name] || {}), folderName, packageFolder };
|
|
218
|
+
assigned.add(pkg.name);
|
|
219
|
+
added = true;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return added;
|
|
142
223
|
}
|
|
143
224
|
|
|
144
225
|
// ---------------------------------------------------------------------------
|
|
145
226
|
// main
|
|
146
227
|
// ---------------------------------------------------------------------------
|
|
147
228
|
async function main(options) {
|
|
229
|
+
// Detect workspace package roots (pnpm-workspace.yaml or package.json
|
|
230
|
+
// workspaces), same as monorepo-version and manifest. Used below to scope
|
|
231
|
+
// discovery of new (not-yet-configured) packages.
|
|
232
|
+
if (!options.children) {
|
|
233
|
+
const detected = resolveWorkspaceChildren(process.cwd());
|
|
234
|
+
if (detected) {
|
|
235
|
+
if (options.debug) console.log(`Auto-detected workspace children: ${detected}`);
|
|
236
|
+
options.children = detected;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --exclude drops specific root directories from whichever children list is
|
|
241
|
+
// in effect, auto-detected or explicit. The same list is also used below to
|
|
242
|
+
// filter individual packages by folder name during generation.
|
|
243
|
+
if (options.exclude.length > 0) {
|
|
244
|
+
options.children = excludeChildren(options.children, options.exclude) || options.children;
|
|
245
|
+
}
|
|
246
|
+
|
|
148
247
|
const configDir = options.platform === 'github' ? '.github' : '.cicd';
|
|
149
248
|
const altConfigDir = options.platform === 'github' ? '.cicd' : '.github';
|
|
150
249
|
const templatePath = path.join(process.cwd(), `${configDir}/templates`);
|
|
151
250
|
const fallbackTemplatePath = path.join(process.cwd(), `${altConfigDir}/templates`);
|
|
152
251
|
|
|
153
|
-
// Try manifest.json first, then fall back to templated.json
|
|
154
252
|
const manifestPath = path.join(process.cwd(), `${configDir}/manifest.json`);
|
|
155
253
|
const templatedJsonPath = path.join(process.cwd(), `${configDir}/templates/templated.json`);
|
|
156
254
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
255
|
+
// ── Load config: manifest.json's "templated" section (if still present, to
|
|
256
|
+
// be migrated out), else templated.json (new or legacy format). Whatever we
|
|
257
|
+
// load is normalized into { templated, cicd } once, up front, so generation
|
|
258
|
+
// below always runs the same (advanced-format) code path.
|
|
259
|
+
let templated;
|
|
260
|
+
let cicd;
|
|
261
|
+
let manifestToPersist = null; // set when manifest.json needs its "templated" section stripped
|
|
262
|
+
let templatedJsonDirty = false; // set when templated.json needs (re)writing
|
|
263
|
+
let dependencies = {}; // manifest.json's local dependency graph, for ordering generation
|
|
264
|
+
|
|
265
|
+
if (existsSync(manifestPath)) {
|
|
266
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8') || '{}');
|
|
267
|
+
dependencies = manifest.dependencies || {};
|
|
268
|
+
if (isAdvancedTemplatedFormat(manifest)) {
|
|
269
|
+
templated = manifest.templated;
|
|
270
|
+
cicd = extractGenerationCicd(manifest.cicd || {});
|
|
271
|
+
delete manifest.templated;
|
|
272
|
+
manifestToPersist = manifest;
|
|
273
|
+
templatedJsonDirty = true;
|
|
274
|
+
}
|
|
162
275
|
}
|
|
163
276
|
|
|
164
|
-
|
|
165
|
-
|
|
277
|
+
if (!templated) {
|
|
278
|
+
if (!existsSync(templatedJsonPath)) {
|
|
279
|
+
throw `enablement-build-template-generate: could not find manifest.json "templated" section at ${configDir}/manifest.json or templated.json at ${configDir}/templates/templated.json`;
|
|
280
|
+
}
|
|
166
281
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
282
|
+
const raw = JSON.parse(readFileSync(templatedJsonPath, 'utf8') || '{}');
|
|
283
|
+
if (isAdvancedTemplatedFormat(raw)) {
|
|
284
|
+
templated = raw.templated;
|
|
285
|
+
cicd = raw.cicd || {};
|
|
286
|
+
if (typeof raw.version !== 'number') {
|
|
287
|
+
// Advanced format but predates the version field: write it back once,
|
|
288
|
+
// purely to backfill it, even if nothing else changed.
|
|
289
|
+
templatedJsonDirty = true;
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const converted = await convertLegacyTemplatedJson(raw, templatePath, options);
|
|
293
|
+
templated = converted.templated;
|
|
294
|
+
cicd = converted.cicd;
|
|
295
|
+
templatedJsonDirty = true;
|
|
171
296
|
}
|
|
172
|
-
config = manifest.templated;
|
|
173
|
-
cicd = manifest.cicd || {};
|
|
174
|
-
} else {
|
|
175
|
-
config = JSON.parse(readFileSync(templatedJsonPath, 'utf8'));
|
|
176
297
|
}
|
|
177
298
|
|
|
299
|
+
// ── Discover packages not yet assigned to any template and add them.
|
|
300
|
+
if (await detectAndAssignNewPackages(templated, cicd, options, templatePath)) {
|
|
301
|
+
templatedJsonDirty = true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Persist. At most one write to each file, only when something changed.
|
|
305
|
+
if (manifestToPersist) {
|
|
306
|
+
writeFileSync(manifestPath, JSON.stringify(manifestToPersist, null, 2), 'utf8');
|
|
307
|
+
}
|
|
308
|
+
if (templatedJsonDirty) {
|
|
309
|
+
writeFileSync(templatedJsonPath, JSON.stringify({ version: TEMPLATED_JSON_ADVANCED_VERSION, templated, cicd }, null, 2), 'utf8');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Generate.
|
|
178
313
|
const plist = [];
|
|
179
314
|
|
|
180
|
-
Object.keys(
|
|
315
|
+
Object.keys(templated).forEach((templateFilename) => {
|
|
181
316
|
plist.push(new Promise((primaryResolve, reject) => {
|
|
182
317
|
try {
|
|
183
318
|
const docTemplate = readFileSync(path.join(templatePath, templateFilename), 'utf8');
|
|
184
319
|
const jlist = [];
|
|
185
320
|
|
|
186
|
-
Object.keys(
|
|
187
|
-
const varConfig =
|
|
188
|
-
|
|
189
|
-
if (useManifest) {
|
|
190
|
-
// New format: varConfig is { pkgName: "template.yml" | [...] }
|
|
191
|
-
jlist.push(expandVarFromManifest(varname, varConfig, cicd, templatePath, fallbackTemplatePath, options));
|
|
192
|
-
} else {
|
|
193
|
-
// Legacy format: varConfig is { rootPath, template, exclude? }
|
|
194
|
-
jlist.push(expandVarFromTemplatedJson(varname, varConfig, templatePath, fallbackTemplatePath, options));
|
|
195
|
-
}
|
|
321
|
+
Object.keys(templated[templateFilename]).forEach((varname) => {
|
|
322
|
+
const varConfig = templated[templateFilename][varname];
|
|
323
|
+
jlist.push(expandVarFromManifest(varname, varConfig, cicd, templatePath, fallbackTemplatePath, options, dependencies));
|
|
196
324
|
});
|
|
197
325
|
|
|
198
326
|
Promise.all(jlist).then((results) => {
|
|
@@ -220,9 +348,10 @@ async function main(options) {
|
|
|
220
348
|
// CLI
|
|
221
349
|
// ---------------------------------------------------------------------------
|
|
222
350
|
let options = {
|
|
223
|
-
platform:
|
|
351
|
+
platform: 'ado',
|
|
224
352
|
exclude: [],
|
|
225
353
|
debug: false,
|
|
354
|
+
children: null,
|
|
226
355
|
};
|
|
227
356
|
|
|
228
357
|
let argv = process.argv;
|
|
@@ -243,20 +372,6 @@ for (let i = 2; i < argv.length; i++) {
|
|
|
243
372
|
}
|
|
244
373
|
}
|
|
245
374
|
|
|
246
|
-
if (!options.platform) {
|
|
247
|
-
const hasGithubManifest = existsSync(path.join(process.cwd(), '.github/manifest.json'));
|
|
248
|
-
const hasAdoManifest = existsSync(path.join(process.cwd(), '.cicd/manifest.json'));
|
|
249
|
-
const hasGithubTemplated = existsSync(path.join(process.cwd(), '.github/templates/templated.json'));
|
|
250
|
-
const hasAdoTemplated = existsSync(path.join(process.cwd(), '.cicd/templates/templated.json'));
|
|
251
|
-
|
|
252
|
-
if (hasGithubManifest || hasGithubTemplated) {
|
|
253
|
-
options.platform = 'github';
|
|
254
|
-
} else if (hasAdoManifest || hasAdoTemplated) {
|
|
255
|
-
options.platform = 'ado';
|
|
256
|
-
} else {
|
|
257
|
-
options.platform = 'ado';
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
375
|
|
|
261
376
|
(async () => {
|
|
262
377
|
try {
|
package/src/lib.mjs
CHANGED
|
@@ -1,4 +1,135 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { readdirSync } from 'fs';
|
|
3
|
+
import { detectCicdType } from 'enablement-build-manifest/src/detect.mjs';
|
|
4
|
+
import { resolveWorkspaceChildren, buildPackageJsonGlob, excludeChildren } from 'enablement-build-manifest/src/workspace.mjs';
|
|
5
|
+
|
|
6
|
+
// Re-exposes the manifest package's pure package-type detector (output/language/
|
|
7
|
+
// tier/packageType from package.json shape or csproj/pyproject.toml/Dockerfile)
|
|
8
|
+
// so template-generate can classify a package without needing manifest.json.
|
|
9
|
+
export { detectCicdType };
|
|
10
|
+
|
|
11
|
+
// Re-exposes the manifest package's workspace root-directory detector
|
|
12
|
+
// (pnpm-workspace.yaml or package.json#workspaces), the exclude filter for it,
|
|
13
|
+
// and the glob it implies, so template-generate can find package roots
|
|
14
|
+
// without needing manifest.json.
|
|
15
|
+
export { resolveWorkspaceChildren, buildPackageJsonGlob, excludeChildren };
|
|
16
|
+
|
|
17
|
+
// Re-exposes the manifest package's dependency-order sort, so a package's
|
|
18
|
+
// local dependencies (per manifest.json's "dependencies") are generated
|
|
19
|
+
// before it, as much as possible.
|
|
20
|
+
export { topologicalSort } from 'enablement-build-manifest/src/dependencyOrder.mjs';
|
|
21
|
+
|
|
22
|
+
// ─── templated.json format helpers ───────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// templated.json schema versions. 1 = legacy rootPath+template shape, 2 = the
|
|
25
|
+
// advanced { templated, cicd } shape. Every write stamps ADVANCED_VERSION;
|
|
26
|
+
// LEGACY_VERSION is only ever inferred for older, unversioned files.
|
|
27
|
+
export const TEMPLATED_JSON_LEGACY_VERSION = 1;
|
|
28
|
+
export const TEMPLATED_JSON_ADVANCED_VERSION = 2;
|
|
29
|
+
|
|
30
|
+
// True when parsed templated.json content is in the advanced format. An
|
|
31
|
+
// explicit "version" property is authoritative and used exclusively — no
|
|
32
|
+
// shape-sniffing once it's present. Only when "version" is absent do we infer
|
|
33
|
+
// it from the legacy content's shape (a top-level "templated" key = advanced).
|
|
34
|
+
export function isAdvancedTemplatedFormat(data) {
|
|
35
|
+
if (!data) return false;
|
|
36
|
+
if (typeof data.version === 'number') return data.version >= TEMPLATED_JSON_ADVANCED_VERSION;
|
|
37
|
+
return !!data.templated;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pick only the per-package fields generation needs (folderName, packageFolder,
|
|
41
|
+
// metadata) out of a fuller manifest.json cicd map, dropping unrelated fields
|
|
42
|
+
// like output/language/tier/packageType/hash/version.
|
|
43
|
+
export function extractGenerationCicd(cicd = {}) {
|
|
44
|
+
const result = {};
|
|
45
|
+
Object.keys(cicd).forEach((pkgName) => {
|
|
46
|
+
const entry = cicd[pkgName] || {};
|
|
47
|
+
const picked = {};
|
|
48
|
+
if (entry.folderName) picked.folderName = entry.folderName;
|
|
49
|
+
if (entry.packageFolder) picked.packageFolder = entry.packageFolder;
|
|
50
|
+
if (entry.metadata) picked.metadata = entry.metadata;
|
|
51
|
+
result[pkgName] = picked;
|
|
52
|
+
});
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Collect every package name already assigned to a template anywhere in the
|
|
57
|
+
// advanced-format templated map, across all pipeline files and placeholders.
|
|
58
|
+
export function collectAssignedPackages(templated = {}) {
|
|
59
|
+
const names = new Set();
|
|
60
|
+
Object.values(templated).forEach((placeholders) => {
|
|
61
|
+
Object.values(placeholders || {}).forEach((pkgMap) => {
|
|
62
|
+
Object.keys(pkgMap || {}).forEach((name) => names.add(name));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
return names;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// List "build" template files: underscore-prefixed .yml files in the templates
|
|
69
|
+
// dir that aren't scan/check templates. Mirrors manifest.mjs's prior convention
|
|
70
|
+
// for choosing a template by detected package type.
|
|
71
|
+
export function listBuildTemplateFiles(templatePath) {
|
|
72
|
+
let files;
|
|
73
|
+
try {
|
|
74
|
+
files = readdirSync(templatePath);
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const withUnderscore = files.filter((f) => f.endsWith('.yml') && f.includes('_'));
|
|
79
|
+
return withUnderscore.filter((f) => !f.includes('scan') && !f.includes('check'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Map a detected cicd "output" value to the keyword used to pick a build template.
|
|
83
|
+
function templateKeywordForOutput(output) {
|
|
84
|
+
if (output === 'pypi') return 'python';
|
|
85
|
+
if (output === 'nuget') return 'dotnet';
|
|
86
|
+
if (output === 'docker') return 'docker';
|
|
87
|
+
return 'node';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Derive the best-fit build template for a detected output type.
|
|
91
|
+
export function deriveTemplateForType(output, buildTemplateFiles) {
|
|
92
|
+
const lookfor = templateKeywordForOutput(output);
|
|
93
|
+
for (const file of buildTemplateFiles) {
|
|
94
|
+
if (file.includes(lookfor)) return file;
|
|
95
|
+
}
|
|
96
|
+
return buildTemplateFiles[0] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Choose where a newly-discovered package should be assigned, mirroring
|
|
100
|
+
// manifest.mjs's prior convention:
|
|
101
|
+
// 1. reuse the placeholder of an existing package under the same packageFolder
|
|
102
|
+
// 2. else reuse the placeholder of an existing package of the same output type
|
|
103
|
+
// 3. else fall back to the default "PACKAGES" placeholder on the first pipeline file
|
|
104
|
+
export function chooseAssignmentForNewPackage({ packageFolder, output, templated, cicd, buildTemplateFiles }) {
|
|
105
|
+
const pipelineFiles = Object.keys(templated);
|
|
106
|
+
const primaryPipelineFile = pipelineFiles[0] || 'build.yml';
|
|
107
|
+
|
|
108
|
+
for (const pipelineFile of pipelineFiles) {
|
|
109
|
+
for (const [placeholder, pkgMap] of Object.entries(templated[pipelineFile] || {})) {
|
|
110
|
+
for (const existingName of Object.keys(pkgMap || {})) {
|
|
111
|
+
if ((cicd[existingName] || {}).packageFolder === packageFolder) {
|
|
112
|
+
return { pipelineFile, placeholder, template: pkgMap[existingName] };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lookfor = templateKeywordForOutput(output);
|
|
119
|
+
for (const [placeholder, pkgMap] of Object.entries(templated[primaryPipelineFile] || {})) {
|
|
120
|
+
for (const tpl of Object.values(pkgMap || {})) {
|
|
121
|
+
if (typeof tpl === 'string' && tpl.toLowerCase().includes(lookfor)) {
|
|
122
|
+
return { pipelineFile: primaryPipelineFile, placeholder, template: tpl };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
pipelineFile: primaryPipelineFile,
|
|
129
|
+
placeholder: 'PACKAGES',
|
|
130
|
+
template: deriveTemplateForType(output, buildTemplateFiles),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
2
133
|
|
|
3
134
|
// Priority order for version manifest files (lower index = higher priority)
|
|
4
135
|
export const MANIFEST_PRIORITY = { 'package.json': 0, 'version.json': 1, 'pyproject.toml': 2 };
|
package/tests/index.test.mjs
CHANGED
|
@@ -88,9 +88,9 @@ function createFixtureWorkspace(mode, options = {}) {
|
|
|
88
88
|
return tmpRoot;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
function runGenerator(cwd) {
|
|
91
|
+
function runGenerator(cwd, extraArgs = []) {
|
|
92
92
|
const scriptPath = fileURLToPath(new URL('../src/index.mjs', import.meta.url));
|
|
93
|
-
execFileSync(process.execPath, [scriptPath, '--platform', 'ado'], {
|
|
93
|
+
execFileSync(process.execPath, [scriptPath, '--platform', 'ado', ...extraArgs], {
|
|
94
94
|
cwd,
|
|
95
95
|
stdio: 'pipe',
|
|
96
96
|
});
|
|
@@ -183,3 +183,363 @@ describe('template-generate mode parity', () => {
|
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
|
|
187
|
+
describe('templated.json migration', () => {
|
|
188
|
+
it("migrates manifest.json's templated section into templated.json and strips it from manifest.json", () => {
|
|
189
|
+
const root = createFixtureWorkspace('manifest');
|
|
190
|
+
try {
|
|
191
|
+
runGenerator(root);
|
|
192
|
+
|
|
193
|
+
const manifest = JSON.parse(readFileSync(path.join(root, '.cicd', 'manifest.json'), 'utf8'));
|
|
194
|
+
expect(manifest.templated).toBeUndefined();
|
|
195
|
+
|
|
196
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
197
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['@scope/enablement-build-alpha-lib']).toBe('job.yml');
|
|
198
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['enablement-build-beta-tool']).toBe('job.yml');
|
|
199
|
+
expect(templatedJson.cicd['@scope/enablement-build-alpha-lib'].packageFolder).toBe('packages');
|
|
200
|
+
expect(templatedJson.version).toBe(2);
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(root, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('converts a legacy templated.json to the new format on disk', () => {
|
|
207
|
+
const root = createFixtureWorkspace('legacy');
|
|
208
|
+
try {
|
|
209
|
+
runGenerator(root);
|
|
210
|
+
|
|
211
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
212
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['@scope/enablement-build-alpha-lib']).toBe('job.yml');
|
|
213
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['enablement-build-beta-tool']).toBe('job.yml');
|
|
214
|
+
expect(templatedJson.cicd['@scope/enablement-build-alpha-lib']).toEqual({ folderName: 'alpha-lib', packageFolder: 'packages' });
|
|
215
|
+
expect(templatedJson.cicd['enablement-build-beta-tool']).toEqual({ folderName: 'beta-tool', packageFolder: 'packages' });
|
|
216
|
+
expect(templatedJson.version).toBe(2);
|
|
217
|
+
} finally {
|
|
218
|
+
rmSync(root, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('backfills the version field on an unversioned advanced-format templated.json, even when nothing else changed', () => {
|
|
223
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
226
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
227
|
+
|
|
228
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), {
|
|
229
|
+
name: 'existing-lib',
|
|
230
|
+
version: '1.0.0',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
234
|
+
writeFileSync(
|
|
235
|
+
path.join(root, '.cicd', 'templates', 'job.yml'),
|
|
236
|
+
'- job: build_{safe}\n displayName: NAME={name}\n',
|
|
237
|
+
'utf8'
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Advanced format already, but predates the version field.
|
|
241
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
242
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
|
|
243
|
+
cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const firstOut = runGenerator(root);
|
|
247
|
+
const templatedAfter = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
248
|
+
|
|
249
|
+
expect(templatedAfter.version).toBe(2);
|
|
250
|
+
expect(templatedAfter.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
251
|
+
|
|
252
|
+
// Running again is now a true no-op: same output, same file.
|
|
253
|
+
const secondOut = runGenerator(root);
|
|
254
|
+
const templatedAfterSecond = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
255
|
+
expect(secondOut).toBe(firstOut);
|
|
256
|
+
expect(templatedAfterSecond).toEqual(templatedAfter);
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('detects a new package, assigns it a template by type, and leaves existing packages untouched', () => {
|
|
263
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
264
|
+
try {
|
|
265
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
266
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
267
|
+
mkdirSync(path.join(root, 'apps', 'new-svc'), { recursive: true });
|
|
268
|
+
|
|
269
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), {
|
|
270
|
+
name: 'existing-lib',
|
|
271
|
+
version: '1.0.0',
|
|
272
|
+
});
|
|
273
|
+
writeJson(path.join(root, 'apps', 'new-svc', 'package.json'), {
|
|
274
|
+
name: 'new-svc',
|
|
275
|
+
version: '1.0.0',
|
|
276
|
+
main: 'index.js',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
280
|
+
writeFileSync(
|
|
281
|
+
path.join(root, '.cicd', 'templates', 'job.yml'),
|
|
282
|
+
'- job: build_{safe}\n displayName: NAME={name}\n',
|
|
283
|
+
'utf8'
|
|
284
|
+
);
|
|
285
|
+
writeFileSync(
|
|
286
|
+
path.join(root, '.cicd', 'templates', '_build_node_package.yml'),
|
|
287
|
+
'- job: node_{safe}\n displayName: NAME={name}\n',
|
|
288
|
+
'utf8'
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
292
|
+
templated: {
|
|
293
|
+
'build.yml': {
|
|
294
|
+
PACKAGES: { 'existing-lib': 'job.yml' },
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
cicd: {
|
|
298
|
+
'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' },
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const firstOut = runGenerator(root);
|
|
303
|
+
const templatedAfterFirst = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
304
|
+
|
|
305
|
+
// Existing package untouched.
|
|
306
|
+
expect(templatedAfterFirst.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
307
|
+
expect(templatedAfterFirst.cicd['existing-lib']).toEqual({ folderName: 'existing-lib', packageFolder: 'packages' });
|
|
308
|
+
|
|
309
|
+
// New package detected, classified as a node package, and assigned the node build template.
|
|
310
|
+
expect(templatedAfterFirst.templated['build.yml'].PACKAGES['new-svc']).toBe('_build_node_package.yml');
|
|
311
|
+
expect(templatedAfterFirst.cicd['new-svc']).toEqual({ folderName: 'new-svc', packageFolder: 'apps' });
|
|
312
|
+
|
|
313
|
+
expect(firstOut).toContain('NAME=existing-lib');
|
|
314
|
+
expect(firstOut).toContain('NAME=new-svc');
|
|
315
|
+
|
|
316
|
+
// Second run: nothing new to detect, output and config are stable.
|
|
317
|
+
const secondOut = runGenerator(root);
|
|
318
|
+
const templatedAfterSecond = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
319
|
+
|
|
320
|
+
expect(secondOut).toBe(firstOut);
|
|
321
|
+
expect(templatedAfterSecond).toEqual(templatedAfterFirst);
|
|
322
|
+
} finally {
|
|
323
|
+
rmSync(root, { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('template-generate new-package assignment convention', () => {
|
|
329
|
+
it('reuses the placeholder/template of an existing package under the same packageFolder (tier 1)', () => {
|
|
330
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
331
|
+
try {
|
|
332
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
333
|
+
mkdirSync(path.join(root, 'packages', 'new-a'), { recursive: true });
|
|
334
|
+
|
|
335
|
+
writeJson(path.join(root, 'packages', 'new-a', 'package.json'), { name: 'new-a', version: '1.0.0', main: 'index.js' });
|
|
336
|
+
|
|
337
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
338
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
|
|
339
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'custom.yml'), '- job: custom_{safe}\n', 'utf8');
|
|
340
|
+
// No "_build_*" template files exist at all — if this fell through to the
|
|
341
|
+
// type-derived fallback, there'd be nothing to assign and new-a would be skipped.
|
|
342
|
+
|
|
343
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
344
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-a': 'custom.yml' } } },
|
|
345
|
+
cicd: { 'existing-a': { folderName: 'existing-a', packageFolder: 'packages' } },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const out = runGenerator(root, ['--children', 'packages']);
|
|
349
|
+
|
|
350
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
351
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['new-a']).toBe('custom.yml');
|
|
352
|
+
expect(templatedJson.cicd['new-a']).toEqual({ folderName: 'new-a', packageFolder: 'packages' });
|
|
353
|
+
expect(out).toContain('custom_newa');
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(root, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('reuses the placeholder/template of an existing package of the same output type when packageFolder differs (tier 2)', () => {
|
|
360
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
361
|
+
try {
|
|
362
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
363
|
+
mkdirSync(path.join(root, 'apps', 'new-node-app'), { recursive: true });
|
|
364
|
+
|
|
365
|
+
writeJson(path.join(root, 'apps', 'new-node-app', 'package.json'), { name: 'new-node-app', version: '1.0.0', main: 'index.js' });
|
|
366
|
+
|
|
367
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n{SERVICES}\n', 'utf8');
|
|
368
|
+
writeFileSync(path.join(root, '.cicd', 'templates', '_build_node_package.yml'), '- job: node_{safe}\n', 'utf8');
|
|
369
|
+
|
|
370
|
+
// existing-node-lib lives under a different packageFolder ("services"),
|
|
371
|
+
// so tier 1 (same packageFolder) cannot match new-node-app ("apps").
|
|
372
|
+
// It's assigned a template containing "node" under a non-default
|
|
373
|
+
// placeholder ("SERVICES"), which tier 2 should reuse verbatim.
|
|
374
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
375
|
+
templated: {
|
|
376
|
+
'build.yml': {
|
|
377
|
+
PACKAGES: {},
|
|
378
|
+
SERVICES: { 'existing-node-lib': '_build_node_package.yml' },
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
cicd: {
|
|
382
|
+
'existing-node-lib': { folderName: 'existing-node-lib', packageFolder: 'services' },
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
runGenerator(root, ['--children', 'apps']);
|
|
387
|
+
|
|
388
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
389
|
+
// Landed in SERVICES (existing-node-lib's placeholder), not the PACKAGES
|
|
390
|
+
// default — proving reuse-by-type (tier 2), not the type-derived fallback (tier 3).
|
|
391
|
+
expect(templatedJson.templated['build.yml'].SERVICES['new-node-app']).toBe('_build_node_package.yml');
|
|
392
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['new-node-app']).toBeUndefined();
|
|
393
|
+
} finally {
|
|
394
|
+
rmSync(root, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe('template-generate --children', () => {
|
|
400
|
+
it('scopes new-package scanning to an explicit --children list, overriding the broad-scan default', () => {
|
|
401
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
402
|
+
try {
|
|
403
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
404
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
405
|
+
mkdirSync(path.join(root, 'tools', 'skip-me'), { recursive: true });
|
|
406
|
+
|
|
407
|
+
// No pnpm-workspace.yaml / package.json#workspaces declared at all, so
|
|
408
|
+
// without --children this would broad-scan and find "tools/skip-me" too.
|
|
409
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), { name: 'existing-lib', version: '1.0.0' });
|
|
410
|
+
writeJson(path.join(root, 'tools', 'skip-me', 'package.json'), { name: 'skip-me', version: '1.0.0', main: 'index.js' });
|
|
411
|
+
|
|
412
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
413
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n displayName: NAME={name}\n', 'utf8');
|
|
414
|
+
|
|
415
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
416
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
|
|
417
|
+
cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
runGenerator(root, ['--children', 'packages']);
|
|
421
|
+
|
|
422
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
423
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
424
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['skip-me']).toBeUndefined();
|
|
425
|
+
expect(templatedJson.cicd['skip-me']).toBeUndefined();
|
|
426
|
+
} finally {
|
|
427
|
+
rmSync(root, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('template-generate --exclude', () => {
|
|
433
|
+
it('drops an excluded root directory from auto-detected new-package scanning', () => {
|
|
434
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
435
|
+
try {
|
|
436
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
437
|
+
mkdirSync(path.join(root, 'packages', 'existing-lib'), { recursive: true });
|
|
438
|
+
mkdirSync(path.join(root, 'tools', 'skip-me'), { recursive: true });
|
|
439
|
+
|
|
440
|
+
writeJson(path.join(root, 'package.json'), {
|
|
441
|
+
name: 'tmp-monorepo',
|
|
442
|
+
private: true,
|
|
443
|
+
workspaces: ['packages/*', 'tools/*'],
|
|
444
|
+
});
|
|
445
|
+
writeJson(path.join(root, 'packages', 'existing-lib', 'package.json'), { name: 'existing-lib', version: '1.0.0' });
|
|
446
|
+
writeJson(path.join(root, 'tools', 'skip-me', 'package.json'), { name: 'skip-me', version: '1.0.0', main: 'index.js' });
|
|
447
|
+
|
|
448
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
449
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n displayName: NAME={name}\n', 'utf8');
|
|
450
|
+
|
|
451
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
452
|
+
templated: { 'build.yml': { PACKAGES: { 'existing-lib': 'job.yml' } } },
|
|
453
|
+
cicd: { 'existing-lib': { folderName: 'existing-lib', packageFolder: 'packages' } },
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
runGenerator(root, ['--exclude', 'tools']);
|
|
457
|
+
|
|
458
|
+
const templatedJson = JSON.parse(readFileSync(path.join(root, '.cicd', 'templates', 'templated.json'), 'utf8'));
|
|
459
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['existing-lib']).toBe('job.yml');
|
|
460
|
+
expect(templatedJson.templated['build.yml'].PACKAGES['skip-me']).toBeUndefined();
|
|
461
|
+
expect(templatedJson.cicd['skip-me']).toBeUndefined();
|
|
462
|
+
} finally {
|
|
463
|
+
rmSync(root, { recursive: true, force: true });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('template-generate dependency ordering', () => {
|
|
469
|
+
it('generates a dependency before its dependent, per manifest.json dependencies', () => {
|
|
470
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
471
|
+
try {
|
|
472
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
473
|
+
|
|
474
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
475
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
|
|
476
|
+
|
|
477
|
+
// Assignment order deliberately does not match dependency order:
|
|
478
|
+
// pkg-a depends on pkg-b depends on pkg-c, but keys are alphabetical.
|
|
479
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
480
|
+
templated: {
|
|
481
|
+
'build.yml': {
|
|
482
|
+
PACKAGES: { 'pkg-a': 'job.yml', 'pkg-b': 'job.yml', 'pkg-c': 'job.yml' },
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
cicd: {
|
|
486
|
+
'pkg-a': { folderName: 'pkg-a', packageFolder: 'packages' },
|
|
487
|
+
'pkg-b': { folderName: 'pkg-b', packageFolder: 'packages' },
|
|
488
|
+
'pkg-c': { folderName: 'pkg-c', packageFolder: 'packages' },
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// manifest.json here only supplies the dependency graph (no "templated").
|
|
493
|
+
writeJson(path.join(root, '.cicd', 'manifest.json'), {
|
|
494
|
+
dependencies: {
|
|
495
|
+
'pkg-a': ['pkg-b'],
|
|
496
|
+
'pkg-b': ['pkg-c'],
|
|
497
|
+
'pkg-c': [],
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const out = runGenerator(root);
|
|
502
|
+
|
|
503
|
+
const posA = out.indexOf('build_pkga');
|
|
504
|
+
const posB = out.indexOf('build_pkgb');
|
|
505
|
+
const posC = out.indexOf('build_pkgc');
|
|
506
|
+
|
|
507
|
+
expect(posC).toBeLessThan(posB);
|
|
508
|
+
expect(posB).toBeLessThan(posA);
|
|
509
|
+
} finally {
|
|
510
|
+
rmSync(root, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe('template-generate --exclude (generation-side)', () => {
|
|
516
|
+
it('omits an already-assigned package from the generated output by folder name', () => {
|
|
517
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'template-generate-'));
|
|
518
|
+
try {
|
|
519
|
+
mkdirSync(path.join(root, '.cicd', 'templates'), { recursive: true });
|
|
520
|
+
|
|
521
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'build.yml'), 'jobs:\n{PACKAGES}\n', 'utf8');
|
|
522
|
+
writeFileSync(path.join(root, '.cicd', 'templates', 'job.yml'), '- job: build_{safe}\n', 'utf8');
|
|
523
|
+
|
|
524
|
+
writeJson(path.join(root, '.cicd', 'templates', 'templated.json'), {
|
|
525
|
+
version: 2,
|
|
526
|
+
templated: {
|
|
527
|
+
'build.yml': {
|
|
528
|
+
PACKAGES: { 'keep-me': 'job.yml', 'drop-me': 'job.yml' },
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
cicd: {
|
|
532
|
+
'keep-me': { folderName: 'keep-me', packageFolder: 'packages' },
|
|
533
|
+
'drop-me': { folderName: 'drop-me', packageFolder: 'packages' },
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const out = runGenerator(root, ['--exclude', 'drop-me']);
|
|
538
|
+
|
|
539
|
+
expect(out).toContain('build_keepme');
|
|
540
|
+
expect(out).not.toContain('build_dropme');
|
|
541
|
+
} finally {
|
|
542
|
+
rmSync(root, { recursive: true, force: true });
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
});
|
package/tests/lib.test.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
2
5
|
import {
|
|
3
6
|
MANIFEST_PRIORITY,
|
|
4
7
|
stripWhitespace,
|
|
@@ -12,6 +15,12 @@ import {
|
|
|
12
15
|
resolveOtherPlaceholders,
|
|
13
16
|
applyVarsToDocTemplate,
|
|
14
17
|
wrapWithBanner,
|
|
18
|
+
isAdvancedTemplatedFormat,
|
|
19
|
+
extractGenerationCicd,
|
|
20
|
+
collectAssignedPackages,
|
|
21
|
+
listBuildTemplateFiles,
|
|
22
|
+
deriveTemplateForType,
|
|
23
|
+
chooseAssignmentForNewPackage,
|
|
15
24
|
} from '../src/lib.mjs';
|
|
16
25
|
|
|
17
26
|
// ─── MANIFEST_PRIORITY ────────────────────────────────────────────────────────
|
|
@@ -354,3 +363,202 @@ describe('wrapWithBanner', () => {
|
|
|
354
363
|
expect(result).toContain('\n\nbody\n\n');
|
|
355
364
|
});
|
|
356
365
|
});
|
|
366
|
+
|
|
367
|
+
// ─── isAdvancedTemplatedFormat ────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
describe('isAdvancedTemplatedFormat', () => {
|
|
370
|
+
it('is true when a top-level "templated" key is present', () => {
|
|
371
|
+
expect(isAdvancedTemplatedFormat({ templated: {}, cicd: {} })).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('is false for the legacy rootPath+template shape', () => {
|
|
375
|
+
expect(isAdvancedTemplatedFormat({ 'build.yml': { PACKAGES: { rootPath: '../packages', template: 'job.yml' } } })).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('is false for null/undefined', () => {
|
|
379
|
+
expect(isAdvancedTemplatedFormat(null)).toBe(false);
|
|
380
|
+
expect(isAdvancedTemplatedFormat(undefined)).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('uses an explicit version property exclusively, ignoring shape', () => {
|
|
384
|
+
// version says legacy even though a "templated" key is present.
|
|
385
|
+
expect(isAdvancedTemplatedFormat({ version: 1, templated: {} })).toBe(false);
|
|
386
|
+
// version says advanced even though the shape looks legacy.
|
|
387
|
+
expect(isAdvancedTemplatedFormat({ version: 2, 'build.yml': { PACKAGES: { rootPath: '../packages', template: 'job.yml' } } })).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ─── extractGenerationCicd ────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
describe('extractGenerationCicd', () => {
|
|
394
|
+
it('keeps only folderName, packageFolder, and metadata', () => {
|
|
395
|
+
const result = extractGenerationCicd({
|
|
396
|
+
'pkg-a': {
|
|
397
|
+
output: 'npm',
|
|
398
|
+
language: 'JavaScript',
|
|
399
|
+
tier: 'component',
|
|
400
|
+
packageFolder: 'packages',
|
|
401
|
+
folderName: 'pkg-a',
|
|
402
|
+
metadata: { owner: 'team-a' },
|
|
403
|
+
hash: 'abc123',
|
|
404
|
+
version: '1.0.0',
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(result).toEqual({
|
|
409
|
+
'pkg-a': {
|
|
410
|
+
packageFolder: 'packages',
|
|
411
|
+
folderName: 'pkg-a',
|
|
412
|
+
metadata: { owner: 'team-a' },
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('omits fields that are absent rather than including them as undefined', () => {
|
|
418
|
+
const result = extractGenerationCicd({ 'pkg-a': { output: 'npm' } });
|
|
419
|
+
expect(result['pkg-a']).toEqual({});
|
|
420
|
+
expect(Object.keys(result['pkg-a'])).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('handles an empty or missing map', () => {
|
|
424
|
+
expect(extractGenerationCicd({})).toEqual({});
|
|
425
|
+
expect(extractGenerationCicd()).toEqual({});
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ─── collectAssignedPackages ──────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
describe('collectAssignedPackages', () => {
|
|
432
|
+
it('collects package names across pipeline files and placeholders', () => {
|
|
433
|
+
const result = collectAssignedPackages({
|
|
434
|
+
'build.yml': {
|
|
435
|
+
PACKAGES: { 'pkg-a': 'job.yml', 'pkg-b': 'job.yml' },
|
|
436
|
+
OTHER: { 'pkg-c': 'job2.yml' },
|
|
437
|
+
},
|
|
438
|
+
'scan.yml': {
|
|
439
|
+
PACKAGES: { 'pkg-a': 'scan.yml' },
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(result).toEqual(new Set(['pkg-a', 'pkg-b', 'pkg-c']));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('returns an empty set for an empty map', () => {
|
|
447
|
+
expect(collectAssignedPackages({})).toEqual(new Set());
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ─── listBuildTemplateFiles ───────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
describe('listBuildTemplateFiles', () => {
|
|
454
|
+
function tmpTemplatesDir(files) {
|
|
455
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'lib-tests-'));
|
|
456
|
+
mkdirSync(path.join(root, 'templates'), { recursive: true });
|
|
457
|
+
for (const f of files) writeFileSync(path.join(root, 'templates', f), '', 'utf8');
|
|
458
|
+
return path.join(root, 'templates');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
it('keeps underscore-prefixed build templates, excluding scan/check', () => {
|
|
462
|
+
const dir = tmpTemplatesDir([
|
|
463
|
+
'_build_node_package.yml',
|
|
464
|
+
'_build_dotnet_package.yml',
|
|
465
|
+
'_scan_node.yml',
|
|
466
|
+
'_check_code.yml',
|
|
467
|
+
'build.yml',
|
|
468
|
+
'notes.txt',
|
|
469
|
+
]);
|
|
470
|
+
try {
|
|
471
|
+
expect(listBuildTemplateFiles(dir).sort()).toEqual(['_build_dotnet_package.yml', '_build_node_package.yml']);
|
|
472
|
+
} finally {
|
|
473
|
+
rmSync(path.dirname(dir), { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('returns an empty array when the templates dir does not exist', () => {
|
|
478
|
+
expect(listBuildTemplateFiles('/does/not/exist')).toEqual([]);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ─── deriveTemplateForType ────────────────────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
describe('deriveTemplateForType', () => {
|
|
485
|
+
const files = ['_build_node_package.yml', '_build_python_package.yml', '_build_dotnet_package.yml', '_build_docker_package.yml'];
|
|
486
|
+
|
|
487
|
+
it('picks the matching build template per output type', () => {
|
|
488
|
+
expect(deriveTemplateForType('npm', files)).toBe('_build_node_package.yml');
|
|
489
|
+
expect(deriveTemplateForType('pypi', files)).toBe('_build_python_package.yml');
|
|
490
|
+
expect(deriveTemplateForType('nuget', files)).toBe('_build_dotnet_package.yml');
|
|
491
|
+
expect(deriveTemplateForType('docker', files)).toBe('_build_docker_package.yml');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('falls back to the first build template for an unknown output type', () => {
|
|
495
|
+
expect(deriveTemplateForType('something-else', ['_build_dotnet_package.yml'])).toBe('_build_dotnet_package.yml');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('returns null when there are no build templates', () => {
|
|
499
|
+
expect(deriveTemplateForType('npm', [])).toBeNull();
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ─── chooseAssignmentForNewPackage ────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
describe('chooseAssignmentForNewPackage', () => {
|
|
506
|
+
const buildTemplateFiles = ['_build_node_package.yml', '_build_python_package.yml'];
|
|
507
|
+
|
|
508
|
+
it('reuses the placeholder of an existing package under the same packageFolder', () => {
|
|
509
|
+
const templated = { 'build.yml': { PACKAGES: { 'pkg-a': 'job.yml' } } };
|
|
510
|
+
const cicd = { 'pkg-a': { packageFolder: 'packages' } };
|
|
511
|
+
|
|
512
|
+
const result = chooseAssignmentForNewPackage({
|
|
513
|
+
packageFolder: 'packages',
|
|
514
|
+
output: 'pypi',
|
|
515
|
+
templated,
|
|
516
|
+
cicd,
|
|
517
|
+
buildTemplateFiles,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'PACKAGES', template: 'job.yml' });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('reuses the placeholder of a package with the same output type when no folder match exists', () => {
|
|
524
|
+
const templated = { 'build.yml': { NODE_JOBS: { 'pkg-a': '_build_node_package.yml' } } };
|
|
525
|
+
const cicd = { 'pkg-a': { packageFolder: 'apps' } };
|
|
526
|
+
|
|
527
|
+
const result = chooseAssignmentForNewPackage({
|
|
528
|
+
packageFolder: 'packages',
|
|
529
|
+
output: 'npm',
|
|
530
|
+
templated,
|
|
531
|
+
cicd,
|
|
532
|
+
buildTemplateFiles,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'NODE_JOBS', template: '_build_node_package.yml' });
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('falls back to the default PACKAGES placeholder with a type-derived template', () => {
|
|
539
|
+
const templated = { 'build.yml': {} };
|
|
540
|
+
const cicd = {};
|
|
541
|
+
|
|
542
|
+
const result = chooseAssignmentForNewPackage({
|
|
543
|
+
packageFolder: 'packages',
|
|
544
|
+
output: 'pypi',
|
|
545
|
+
templated,
|
|
546
|
+
cicd,
|
|
547
|
+
buildTemplateFiles,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'PACKAGES', template: '_build_python_package.yml' });
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('defaults to build.yml when there is no pipeline file yet', () => {
|
|
554
|
+
const result = chooseAssignmentForNewPackage({
|
|
555
|
+
packageFolder: 'packages',
|
|
556
|
+
output: 'npm',
|
|
557
|
+
templated: {},
|
|
558
|
+
cicd: {},
|
|
559
|
+
buildTemplateFiles,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
expect(result).toEqual({ pipelineFile: 'build.yml', placeholder: 'PACKAGES', template: '_build_node_package.yml' });
|
|
563
|
+
});
|
|
564
|
+
});
|