@mnci/cli 4.35.0 → 4.37.0

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/dist/cli.js CHANGED
@@ -429,6 +429,131 @@ function fileExists(path) {
429
429
  return (0, import_node_fs3.existsSync)(path);
430
430
  }
431
431
 
432
+ // src/pipeline-customization/pipeline-markers.algorithm.ts
433
+ var CHOOSABLE_PHASES = ["pack", "release"];
434
+ var SLOT_BEGIN = /^(\s*)# mnci:slot (\S+)/;
435
+ var SLOT_END = /^\s*# mnci:slot-end \S+/;
436
+ var PHASE_BEGIN = /^(\s*)# mnci:phase (\S+)/;
437
+ var PHASE_END = /^\s*# mnci:phase-end (\S+)/;
438
+ var SWITCHED_OFF_HINT = "# mnci: switched off. Uncomment the lines below to run it again (mnci doctor lists it).";
439
+ function slotMarkers(name, indent) {
440
+ return [
441
+ `${indent}# mnci:slot ${name} (your own steps go between these two lines; mnci upgrade keeps them)`,
442
+ `${indent}# mnci:slot-end ${name}`
443
+ ].join("\n");
444
+ }
445
+ function phaseStart(name, indent) {
446
+ return `${indent}# mnci:phase ${name} (delete or comment this block to switch it off; mnci upgrade keeps it off)`;
447
+ }
448
+ function phaseEnd(name, indent) {
449
+ return `${indent}# mnci:phase-end ${name}`;
450
+ }
451
+ function extractSlots(text) {
452
+ const slots = /* @__PURE__ */ new Map();
453
+ const lines = text.split("\n");
454
+ for (let index = 0; index < lines.length; index++) {
455
+ const begin = SLOT_BEGIN.exec(lines[index]);
456
+ if (!begin) {
457
+ continue;
458
+ }
459
+ const [, indent, name] = begin;
460
+ const content = [];
461
+ for (index++; index < lines.length && !SLOT_END.test(lines[index]); index++) {
462
+ content.push(lines[index].startsWith(indent) ? lines[index].slice(indent.length) : lines[index].trimStart());
463
+ }
464
+ slots.set(name, content);
465
+ index--;
466
+ }
467
+ return slots;
468
+ }
469
+ function injectSlots(generated, slots) {
470
+ const lines = generated.split("\n");
471
+ const result = [];
472
+ for (let index = 0; index < lines.length; index++) {
473
+ result.push(lines[index]);
474
+ const begin = SLOT_BEGIN.exec(lines[index]);
475
+ if (!begin) {
476
+ continue;
477
+ }
478
+ const [, indent, name] = begin;
479
+ const content = slots.get(name) ?? [];
480
+ for (const line of content) {
481
+ result.push(line === "" ? "" : `${indent}${line}`);
482
+ }
483
+ while (index + 1 < lines.length && !SLOT_END.test(lines[index + 1])) {
484
+ index++;
485
+ }
486
+ }
487
+ return result.join("\n");
488
+ }
489
+ function switchedOffPhases(text) {
490
+ const lines = text.split("\n");
491
+ if (lines.every((line) => !(PHASE_BEGIN.test(line) || SLOT_BEGIN.test(line)))) {
492
+ return [];
493
+ }
494
+ return CHOOSABLE_PHASES.filter((phase) => {
495
+ const begin = lines.findIndex((line) => PHASE_BEGIN.exec(line)?.[2] === phase);
496
+ if (begin === -1) {
497
+ return true;
498
+ }
499
+ for (let index = begin + 1; index < lines.length && PHASE_END.exec(lines[index])?.[1] !== phase; index++) {
500
+ const line = lines[index].trim();
501
+ if (line !== "" && !line.startsWith("#")) {
502
+ return false;
503
+ }
504
+ }
505
+ return true;
506
+ });
507
+ }
508
+ function commentOutPhases(generated, phases) {
509
+ if (phases.length === 0) {
510
+ return generated;
511
+ }
512
+ const lines = generated.split("\n");
513
+ const result = [];
514
+ for (let index = 0; index < lines.length; index++) {
515
+ result.push(lines[index]);
516
+ const begin = PHASE_BEGIN.exec(lines[index]);
517
+ if (!begin || !phases.includes(begin[2])) {
518
+ continue;
519
+ }
520
+ const [, indent, name] = begin;
521
+ result.push(`${indent}${SWITCHED_OFF_HINT}`);
522
+ for (index++; index < lines.length && PHASE_END.exec(lines[index])?.[1] !== name; index++) {
523
+ const line = lines[index];
524
+ result.push(line.trim() === "" || line.trimStart().startsWith("#") ? line : `${indent}# ${line.slice(indent.length)}`);
525
+ }
526
+ index--;
527
+ }
528
+ return result.join("\n");
529
+ }
530
+ function inspectPipeline(text) {
531
+ return {
532
+ verifyActive: text.split("\n").some((line) => !line.trimStart().startsWith("#") && /\bci verify\b/.test(line)),
533
+ switchedOff: switchedOffPhases(text)
534
+ };
535
+ }
536
+
537
+ // src/pipeline-customization/merge-pipeline.use-case.ts
538
+ function mergePipeline(existing, generated) {
539
+ if (existing === void 0) {
540
+ return { text: generated, notes: [] };
541
+ }
542
+ const normalized = existing.replaceAll("\r\n", "\n");
543
+ if (!/^\s*# mnci:(?:slot|phase) /m.test(normalized)) {
544
+ return {
545
+ text: generated,
546
+ notes: ['This pipeline was written before mnci kept user slots, so it was regenerated whole. Edits outside the generated steps are in git history: re-apply them inside the new "# mnci:slot" blocks.']
547
+ };
548
+ }
549
+ const off = switchedOffPhases(normalized);
550
+ const text = commentOutPhases(injectSlots(generated, extractSlots(normalized)), off);
551
+ return {
552
+ text,
553
+ notes: off.map((phase) => `The ${phase} phase is switched off in this pipeline and stays off.`)
554
+ };
555
+ }
556
+
432
557
  // src/workspace-overlay/overlay.use-case.ts
433
558
  var DEFAULT_STACK = { testRunner: "jest" };
434
559
  var TS_COMPILER_DEPENDENCIES = {
@@ -717,9 +842,9 @@ function devcontainerJson(workspaceName) {
717
842
  // precisely the drift this file exists to remove.
718
843
  `npm install -g npm@${NPM_VERSION}`,
719
844
  "npm ci",
720
- "npm run python:install",
721
- GOLANGCI_LINT_INSTALL_GUARD,
722
- FLUTTER_SDK_INSTALL_GUARD
845
+ // The same command the pipeline runs: the Python, Go and Flutter toolchains, each
846
+ // skipped when the workspace has no project in that language.
847
+ "npx mnci ci setup"
723
848
  ].join(" && "),
724
849
  // The same recommendations the `.code-workspace` file carries, so opening
725
850
  // the folder in a container suggests the identical toolset.
@@ -918,6 +1043,13 @@ function isUnmodifiedMnciEslintConfig(content) {
918
1043
  const code = content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("//"));
919
1044
  return code.length === 2 && code[0] === "import mnci from '@mnci/eslint-config'" && code[1] === "export default mnci({ workspaceRoot: import.meta.dirname })";
920
1045
  }
1046
+ function writePipeline(path, onProgress, generated) {
1047
+ const { text, notes } = mergePipeline((0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : void 0, generated);
1048
+ for (const note of notes) {
1049
+ onProgress(` ${note}`);
1050
+ }
1051
+ writeFileEnsured(path, text);
1052
+ }
921
1053
  function writeEslintEntryPoint(workspaceRoot, onProgress) {
922
1054
  const path = (0, import_node_path3.join)(workspaceRoot, ESLINT_USER_FILENAME);
923
1055
  if ((0, import_node_fs4.existsSync)(path) && !isUnmodifiedMnciEslintConfig((0, import_node_fs4.readFileSync)(path, "utf8"))) {
@@ -1158,13 +1290,7 @@ function nugetConfigContent(registry, scope) {
1158
1290
  }
1159
1291
  var PYTHON_INSTALL_GUARD = `node -e "if(!require('node:fs').existsSync('requirements-dev.txt')){console.log('No Python projects - skipping.');process.exit(0)}const py=process.platform==='win32'?'python':'python3';process.exit(require('node:child_process').spawnSync(py+' -m pip install -r requirements-dev.txt',{stdio:'inherit',shell:true}).status ?? 1)"`;
1160
1292
  var PYTHON_WORKSPACE_INSTALL_GUARD = `node -e "const fs=require('node:fs'),path=require('node:path');const editableDirs=[...fs.globSync('apps/*/pyproject.toml'),...fs.globSync('python-packages/*/pyproject.toml'),...fs.globSync('libs/*/pyproject.toml')].map((p)=>path.dirname(p));const requirementsFiles=fs.globSync('apps/*/requirements.txt');if(editableDirs.length===0&&requirementsFiles.length===0){console.log('No Python projects - skipping.');process.exit(0)}const args=['-m','pip','install','--quiet',...editableDirs.flatMap((d)=>['-e',d]),...requirementsFiles.flatMap((f)=>['-r',f])];const py=process.platform==='win32'?'python':'python3';process.exit(require('node:child_process').spawnSync(py,args,{stdio:'inherit'}).status ?? 1)"`;
1161
- var PIP_AUDIT_GUARD = `node -e "if(!require('node:fs').existsSync('requirements-dev.txt')){console.log('No Python projects - skipping.');process.exit(0)}const py=process.platform==='win32'?'python':'python3';require('node:child_process').spawnSync(py,['-m','pip_audit'],{stdio:'inherit'});process.exit(0)"`;
1162
- var GO_MODULE_DOWNLOAD_GUARD = `node -e "if(!require('node:fs').existsSync('go.mod')){console.log('No Go projects - skipping.');process.exit(0)}process.exit(require('node:child_process').spawnSync('go',['mod','download'],{stdio:'inherit'}).status ?? 1)"`;
1163
1293
  var GOLANGCI_LINT_VERSION = "2.14.0";
1164
- var GOLANGCI_LINT_INSTALL_GUARD = `node -e "const fs=require('node:fs'),cp=require('node:child_process'),path=require('node:path'),os=require('node:os'),crypto=require('node:crypto');if(!fs.existsSync('go.mod')){console.log('No Go projects - skipping.');process.exit(0)}if(cp.spawnSync('golangci-lint',['--version'],{stdio:'ignore'}).status===0){console.log('golangci-lint already installed - skipping.');process.exit(0)}const v='${GOLANGCI_LINT_VERSION}';const fallback=reason=>{console.log('Prebuilt golangci-lint '+v+' unavailable ('+reason+') - building it with go install instead.');process.exit(cp.spawnSync('go',['install','github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v'+v],{stdio:'inherit'}).status ?? 1)};const plat={linux:'linux',darwin:'darwin',win32:'windows'}[process.platform],arch={x64:'amd64',arm64:'arm64'}[process.arch];if(!plat||!arch)fallback('no prebuilt release for '+process.platform+'/'+process.arch);const gopath=cp.spawnSync('go',['env','GOPATH'],{encoding:'utf8'});if(gopath.status!==0)fallback('go env GOPATH failed');const name='golangci-lint-'+v+'-'+plat+'-'+arch,file=name+(plat==='windows'?'.zip':'.tar.gz'),base='https://github.com/golangci/golangci-lint/releases/download/v'+v+'/';const get=async u=>{const r=await fetch(u,{signal:AbortSignal.timeout(60000)});if(!r.ok)throw new Error('HTTP '+r.status+' for '+u);return Buffer.from(await r.arrayBuffer())};(async()=>{` + String.raw`const sums=(await get(base+'golangci-lint-'+v+'-checksums.txt')).toString('utf8').split('\n').map(l=>l.trim().split(' ').filter(Boolean));` + `const entry=sums.find(p=>p[1]===file);if(!entry)throw new Error(file+' is not in the release checksums');const archive=await get(base+file);if(crypto.createHash('sha256').update(archive).digest('hex')!==entry[0])throw new Error('checksum mismatch for '+file);const tmp=fs.mkdtempSync(path.join(os.tmpdir(),'golangci-lint-')),archivePath=path.join(tmp,file);fs.writeFileSync(archivePath,archive);const tar=plat==='windows'?path.join(process.env.SystemRoot||'','System32','tar.exe'):'tar';const x=cp.spawnSync(tar,['-xf',archivePath,'-C',tmp],{stdio:'inherit'});if(x.status!==0)throw new Error('could not extract '+file);const exe='golangci-lint'+(plat==='windows'?'.exe':''),bin=path.join(gopath.stdout.trim(),'bin');fs.mkdirSync(bin,{recursive:true});fs.copyFileSync(path.join(tmp,name,exe),path.join(bin,exe));fs.chmodSync(path.join(bin,exe),0o755);fs.rmSync(tmp,{recursive:true,force:true});console.log('golangci-lint '+v+' installed from its checksum-verified prebuilt release into '+bin)})().catch(error=>fallback(error.message))"`;
1165
- var GO_TOOL_PATH_PRELUDE = "const fs=require('node:fs'),cp=require('node:child_process');if(!fs.existsSync('go.mod')){console.log('No Go projects - skipping.');process.exit(0)}const r=cp.spawnSync('go',['env','GOPATH'],{encoding:'utf8'});if(r.status!==0){console.log('Could not resolve GOPATH - skipping.');process.exit(0)}const bin=require('node:path').join(r.stdout.trim(),'bin');";
1166
- var GO_TOOL_PATH_AZURE = `node -e "${GO_TOOL_PATH_PRELUDE}console.log('##vso[task.prependpath]'+bin)"`;
1167
- var GO_TOOL_PATH_GITHUB = `node -e "${GO_TOOL_PATH_PRELUDE}if(!process.env.GITHUB_PATH){console.log('Not running in GitHub Actions - skipping.');process.exit(0)}fs.appendFileSync(process.env.GITHUB_PATH,bin+${String.raw`'\n'`})"`;
1168
1294
  var FLUTTER_SDK_VERSION = "3.44.8";
1169
1295
  var NODE_VERSION = "24";
1170
1296
  var ACTION_VERSIONS = {
@@ -1177,24 +1303,10 @@ var ACTION_VERSIONS = {
1177
1303
  var NPM_VERSION = "11";
1178
1304
  var DOTNET_SDK_VERSION = "10.0.x";
1179
1305
  var DOTNET_DETECT_AZURE = `node -e "const fs=require('node:fs');const has=[...fs.globSync('apps/*/*.csproj'),...fs.globSync('packages/*/*.csproj'),...fs.globSync('libs/*/*.csproj')].length>0;console.log('##vso[task.setvariable variable=hasDotnetProjects]'+has)"`;
1180
- var FLUTTER_SDK_DIRECTORY_EXPRESSION = `require('node:path').join(require('node:os').homedir(),'.mnci-flutter-${FLUTTER_SDK_VERSION}')`;
1181
- var FLUTTER_SDK_INSTALL_GUARD = `node -e "const fs=require('node:fs'),cp=require('node:child_process');if(!fs.existsSync('pubspec.yaml')){console.log('No Flutter projects - skipping.');process.exit(0)}if(cp.spawnSync('flutter',['--version'],{stdio:'ignore',shell:process.platform==='win32'}).status===0){console.log('Flutter SDK already on PATH - skipping.');process.exit(0)}const sdk=${FLUTTER_SDK_DIRECTORY_EXPRESSION};if(fs.existsSync(sdk)){console.log('Flutter SDK already installed at '+sdk+' - skipping.');process.exit(0)}process.exit(cp.spawnSync('git',['clone','--depth','1','--branch','${FLUTTER_SDK_VERSION}','https://github.com/flutter/flutter.git',sdk],{stdio:'inherit'}).status ?? 1)"`;
1182
- var FLUTTER_TOOL_PATH_PRELUDE = `const fs=require('node:fs'),path=require('node:path');if(!fs.existsSync('pubspec.yaml')){console.log('No Flutter projects - skipping.');process.exit(0)}const sdk=${FLUTTER_SDK_DIRECTORY_EXPRESSION};if(!fs.existsSync(sdk)){console.log('Flutter SDK was not installed by mnci (already on PATH) - skipping.');process.exit(0)}const bin=path.join(sdk,'bin');`;
1183
- var FLUTTER_TOOL_PATH_AZURE = `node -e "${FLUTTER_TOOL_PATH_PRELUDE}console.log('##vso[task.prependpath]'+bin)"`;
1184
- var FLUTTER_TOOL_PATH_GITHUB = `node -e "${FLUTTER_TOOL_PATH_PRELUDE}if(!process.env.GITHUB_PATH){console.log('Not running in GitHub Actions - skipping.');process.exit(0)}fs.appendFileSync(process.env.GITHUB_PATH,bin+${String.raw`'\n'`})"`;
1185
- var FLUTTER_PUB_GET_GUARD = `node -e "const fs=require('node:fs');if(!fs.existsSync('pubspec.yaml')){console.log('No Flutter projects - skipping.');process.exit(0)}process.exit(require('node:child_process').spawnSync('flutter',['pub','get'],{stdio:'inherit',shell:process.platform==='win32'}).status ?? 1)"`;
1186
- var NPM_AUDIT_STEP = `node -e "const cp=require('node:child_process');const r=cp.spawnSync('npm',['audit','--json'],{encoding:'utf8',shell:process.platform==='win32',maxBuffer:33554432});let d;try{d=JSON.parse(r.stdout)}catch{console.log('npm audit produced no JSON (exit '+r.status+') - not blocking on a broken audit.');process.exit(0)}const all=Object.values(d.vulnerabilities||{});const BLOCK=['critical','high','moderate'];const major=v=>v.fixAvailable&&typeof v.fixAvailable==='object'&&v.fixAvailable.isSemVerMajor;const act=all.filter(v=>v.fixAvailable&&BLOCK.includes(v.severity)&&!major(v));const rest=all.filter(v=>!act.includes(v));for(const v of rest)console.log(' note ['+v.severity+'] '+v.name+(!v.fixAvailable?' - NO fix available upstream, nothing to do here':major(v)?' - only a semver-major change would remove it (often a parent downgrade), not applied automatically':' - fix available, below the blocking threshold'));if(act.length===0){console.log('npm audit - '+all.length+' advisory(ies), none actionable at moderate or above.');process.exit(0)}for(const v of act)console.log(' BLOCKING ['+v.severity+'] '+v.name+' - fix available');console.log('Each has a published fix. Add a targeted overrides entry in package.json rather than npm audit fix --force.');process.exit(1)"`;
1187
1306
  var VERIFY_TARGETS = "lint,typecheck,test,build";
1188
- function affectedOrAllGuard(exclude = "") {
1189
- const extra = exclude === "" ? "" : `+' ${exclude}'`;
1190
- return `node -e "const cp=require('node:child_process');const T='${VERIFY_TARGETS}';const all=()=>process.exit(cp.spawnSync('npx nx run-many -t '+T${extra},{stdio:'inherit',shell:true}).status ?? 1);const ref=process.env.GITHUB_BASE_REF||process.env.SYSTEM_PULLREQUEST_TARGETBRANCH||'';if(!ref){console.log('Not a pull request - verifying EVERY project.');all()}const target=ref.replace('refs/heads/','');const mergeBase=r=>{const o=cp.spawnSync('git',['merge-base',r,'HEAD'],{encoding:'utf8'});return o.status===0?o.stdout.trim():''};let base=mergeBase('origin/'+target);if(!base){console.log('No origin/'+target+' ref - fetching it to resolve a merge-base.');cp.spawnSync('git',['fetch','--no-tags','origin',target],{stdio:'inherit'});base=mergeBase('FETCH_HEAD')}if(!base){console.log('Could not resolve a merge-base with '+target+' - verifying EVERY project.');all()}console.log('Pull request against '+target+' - verifying projects affected since '+base);process.exit(cp.spawnSync('npx nx affected -t '+T+' --base='+base${extra},{stdio:'inherit',shell:true}).status ?? 1)"`;
1191
- }
1192
- var AFFECTED_OR_ALL_GUARD = affectedOrAllGuard();
1193
- var AFFECTED_OR_ALL_GUARD_WITHOUT_NATIVE = affectedOrAllGuard(`--exclude=tag:${GO_CGO_TAG}`);
1194
1307
  var NATIVE_TARGETS = "lint,test,build-native,package-native";
1195
- var NATIVE_BUILD_COMMAND = `npx nx run-many -t ${NATIVE_TARGETS} --projects=tag:${GO_CGO_TAG}`;
1196
1308
  var NATIVE_LINUX_PREREQUISITES = "sudo apt-get update && sudo apt-get install -y gcc pkg-config";
1197
- function githubNativeJob(npmAuthName, npmAuthValue, onMain) {
1309
+ function githubNativeJob(npmAuthName, npmAuthValue) {
1198
1310
  return `
1199
1311
  # Apps that need a C toolchain (mnci add go-app --cgo) cannot be cross-compiled
1200
1312
  # from the job above, so each is linted, tested, built and packaged here, on a
@@ -1229,14 +1341,10 @@ function githubNativeJob(npmAuthName, npmAuthValue, onMain) {
1229
1341
  env:
1230
1342
  ${npmAuthName}: ${npmAuthValue}
1231
1343
 
1232
- - run: ${GO_MODULE_DOWNLOAD_GUARD}
1233
- name: Download Go module dependencies
1234
-
1235
- - run: ${GOLANGCI_LINT_INSTALL_GUARD}
1236
- name: Install golangci-lint
1237
-
1238
- - run: ${GO_TOOL_PATH_GITHUB}
1239
- name: Add Go tool bin to PATH
1344
+ # The Go toolchain: the modules, and a checksum-verified golangci-lint put
1345
+ # on PATH. The other toolchains are skipped cleanly when absent.
1346
+ - run: npx mnci ci setup
1347
+ name: Set up the Go toolchain
1240
1348
 
1241
1349
  # A C compiler and pkg-config, which is all mnci can know a cgo app needs.
1242
1350
  # Add the -dev packages your app links to this line, for example
@@ -1246,22 +1354,20 @@ function githubNativeJob(npmAuthName, npmAuthValue, onMain) {
1246
1354
  name: Install native prerequisites (Linux)
1247
1355
  if: \${{ runner.os == 'Linux' }}
1248
1356
 
1249
- - run: ${NATIVE_BUILD_COMMAND}
1357
+ # Lints, tests, builds and packages the native apps on this OS. On a push to
1358
+ # main, a releasable native app (--cgo with --release) also gets this OS's zip
1359
+ # attached to the GitHub Release the ci job just created, stamped with the
1360
+ # tag's version.
1361
+ - run: npx mnci ci native
1250
1362
  name: Lint, test, build and package the native apps
1363
+ env:
1364
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1251
1365
 
1252
1366
  - uses: actions/upload-artifact@${ACTION_VERSIONS["actions/upload-artifact"]}
1253
1367
  with:
1254
1368
  name: native-\${{ matrix.os }}
1255
1369
  path: dist/drop
1256
1370
  if-no-files-found: ignore
1257
-
1258
- # A releasable native app (--cgo with --release) gets this OS's zip attached to
1259
- # the GitHub Release the ci job just created, stamped with the tag's version.
1260
- - run: node tools/go-app-release.cjs assets --native
1261
- name: Attach this OS's zip to the GitHub Release (releasable native apps)
1262
- if: \${{ ${onMain} && hashFiles('tools/go-app-release.cjs') != '' }}
1263
- env:
1264
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1265
1371
  `;
1266
1372
  }
1267
1373
  function indentYaml(text, spaces) {
@@ -1306,40 +1412,12 @@ ${matrix}
1306
1412
  ${indentYaml(nativeSteps, 4)}
1307
1413
  `;
1308
1414
  }
1309
- var PACK_APPS_GUARD = `node -e "const fs=require('node:fs');fs.mkdirSync('dist/drop',{recursive:true});const hasProjectJson=fs.globSync('apps/*/project.json').length>0;const hasInlineNx=fs.globSync('apps/*/package.json').some((f)=>{try{return Boolean(JSON.parse(fs.readFileSync(f,'utf8')).nx)}catch{return false}});const hasCsproj=fs.globSync('apps/*/*.csproj').length>0;if(!hasProjectJson&&!hasInlineNx&&!hasCsproj){console.log('No apps to pack - skipping.');process.exit(0)}process.exit(require('node:child_process').spawnSync('npx nx run-many -t package',{stdio:'inherit',shell:true}).status ?? 1)"`;
1310
- var NPM_AUTH_PREFLIGHT = `node -e "const fs=require('node:fs'),cp=require('node:child_process');if(fs.globSync('packages/*/package.json').length===0){console.log('No npm packages to release - skipping.');process.exit(0)}if(!process.env.NODE_AUTH_TOKEN){console.error('NPM_TOKEN is empty or unset, so the publish would fail AFTER nx release has already tagged. Add it as a repository secret named exactly NPM_TOKEN - under Actions, not the Dependabot or Codespaces tab, and not an Environment secret. Use an npm Automation token - a Publish token is refused by 2FA in CI.');process.exit(1)}const r=cp.spawnSync('npm',['whoami','--registry=https://registry.npmjs.org/'],{encoding:'utf8',shell:process.platform==='win32'});if(r.status!==0){console.error('NPM_TOKEN is set but the registry rejected it - '+((r.stderr||'')+(r.stdout||'')).trim()+'. Check that it has not expired and that it grants publish rights on this scope.');process.exit(1)}console.log('npm auth OK as '+r.stdout.trim())"`;
1311
- function npmAuthPreflight(registryKind) {
1312
- return registryKind === "npm" ? NPM_AUTH_PREFLIGHT : "";
1313
- }
1314
- var PYPI_RELEASE_PREFLIGHT = String.raw`node -e "const fs=require('node:fs');const files=fs.globSync('python-packages/*/pyproject.toml');if(files.length===0){console.log('No Python packages to release - skipping.');process.exit(0)}if(!process.env.PYPI_TOKEN){console.error('PYPI_TOKEN is empty or unset, so the publish would fail AFTER nx release has already tagged. Add it as a repository secret named exactly PYPI_TOKEN - under Actions, not the Dependabot or Codespaces tab, and not an Environment secret.');process.exit(1)}if(!process.env.PYPI_TOKEN.startsWith('pypi-')){console.error('PYPI_TOKEN does not look like a PyPI API token - every one of them begins with pypi-. A password or a truncated paste is rejected only at upload time, which is AFTER nx release has tagged.');process.exit(1)}const names=files.map(f=>{const t=fs.readFileSync(f,'utf8');const b=t.slice(t.indexOf('[project]'));const l=b.split('\n').find(x=>x.trim().startsWith('name'));return l?l.split('=')[1].replace(/[^a-zA-Z0-9._-]/g,''):''}).filter(Boolean);(async()=>{const fresh=[];for(const n of names){try{const r=await fetch('https://pypi.org/pypi/'+n.toLowerCase().replace(/[-_.]+/g,'-')+'/json',{signal:AbortSignal.timeout(10000)});if(r.status===404)fresh.push(n)}catch{console.log('Could not reach PyPI to check '+n+' - continuing.')}}if(fresh.length===0){console.log('Every Python package already exists on PyPI - this release creates none.');return}console.log('NOTE - this release may CREATE '+fresh.length+' new PyPI project(s) - '+fresh.join(', ')+'. Project creation is rate limited per account, and a 429 there arrives AFTER nx release has tagged - leaving those versions tagged with nothing published, and skipped forever, because the next run resolves the current version from the newest tag. If that happens, delete the tags for the versions that did not publish before releasing again. PyPI offers no way to check the limit, or the token, in advance.')})()"`;
1315
- function pypiReleasePreflight(registryKind) {
1316
- return registryKind === "npm" ? PYPI_RELEASE_PREFLIGHT : "";
1317
- }
1318
- var SHALLOW_CLONE_GUARD = `node -e "const r=require('node:child_process').spawnSync('git',['rev-parse','--is-shallow-repository'],{encoding:'utf8'});if(r.status!==0){console.error('Could not determine whether this checkout has full history (git rev-parse --is-shallow-repository failed) - refusing to release. '+(r.stderr||'').trim());process.exit(1)}if(r.stdout.trim()==='true'){console.error('This checkout is a shallow clone. nx release resolves the current version of each package from its git tag, and silently falls back to the permanently-stale on-disk version when a tag cannot be found - which can propose, and publish, a version DOWNGRADE. Fetch full history before releasing - set fetchDepth (Azure) or fetch-depth (GitHub) to 0.');process.exit(1)}"`;
1319
- function releaseGuard(pythonPublishEnv, nugetPublishEnv) {
1320
- return String.raw`node -e "const fs=require('node:fs'),cp=require('node:child_process');const npmCount=fs.globSync('packages/*/package.json').length;const csharpCount=fs.globSync('packages/*/*.csproj').length;const pythonCount=fs.globSync('python-packages/*/pyproject.toml').length;const dartCount=fs.globSync('packages/*/pubspec.yaml').length;const vscodeCount=fs.globSync('apps/*/package.json').filter(p=>{try{return(JSON.parse(fs.readFileSync(p,'utf8')).nx?.tags||[]).includes('${VSCODE_EXTENSION_TAG}')}catch{return false}}).length;const goCount=fs.globSync('apps/*/project.json').filter(p=>{try{return(JSON.parse(fs.readFileSync(p,'utf8')).tags||[]).includes('${GO_RELEASE_TAG}')}catch{return false}}).length;const hasNpm=npmCount>0;const hasPython=pythonCount>0;const hasCsharp=csharpCount>0;const hasDart=dartCount>0;const hasVscode=vscodeCount>0;const hasGo=goCount>0;if(!hasNpm&&!hasPython&&!hasCsharp&&!hasDart&&!hasVscode&&!hasGo){console.log('Nothing to release - skipping.');process.exit(0)}const specifier=process.env.RELEASE_SPECIFIER||'';let releaseCmd='npx nx release --yes';if(specifier){if(!/^(major|minor|patch|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(specifier)){console.error('RELEASE_SPECIFIER value \''+specifier+'\' is invalid - use major, minor, patch, or an exact version like 1.2.3.');process.exit(1)}const releaseProjectCount=npmCount+csharpCount+pythonCount+dartCount+vscodeCount+goCount;if(/^(major|minor|patch)$/.test(specifier)&&releaseProjectCount>1){console.error('RELEASE_SPECIFIER is a keyword (\''+specifier+'\') but this workspace has '+releaseProjectCount+' releasable packages - a keyword under-bumps interdependent packages, because nx computes the dependency-bump pass from a stale cached version. Set RELEASE_SPECIFIER to an exact version instead, or clear it.');process.exit(1)}releaseCmd='npx nx release '+specifier+' --yes'}const env={...process.env};${pythonPublishEnv}${nugetPublishEnv}process.exit(cp.spawnSync(releaseCmd,{stdio:'inherit',shell:true,env}).status ?? 1)"`;
1321
- }
1322
1415
  var PYPI_TOKEN_VARIABLE = "PYPI_TOKEN";
1323
1416
  var AZURE_PAT_VARIABLE = "PAT";
1324
- function azurePatPreflight(countExpression, subject) {
1325
- return `if(!process.env.${AZURE_PAT_VARIABLE}){console.error('This workspace has '+${countExpression}+' ${subject} package(s) to publish to Azure Artifacts but ${AZURE_PAT_VARIABLE} is empty. Add your base64-encoded Azure DevOps PAT as a secret named ${AZURE_PAT_VARIABLE}, or remove the ${subject} packages from release.projects.');process.exit(1)}`;
1326
- }
1327
1417
  function pypiTokenEnvLine(registryKind, variableReference, indent) {
1328
1418
  return registryKind === "npm" ? `
1329
1419
  ${indent}${PYPI_TOKEN_VARIABLE}: ${variableReference(PYPI_TOKEN_VARIABLE)}` : "";
1330
1420
  }
1331
- function pythonPublishEnvFragment(pythonPublishUrl2, registryKind) {
1332
- if (pythonPublishUrl2 !== void 0) {
1333
- return `if(hasPython){${azurePatPreflight("pythonCount", "Python")}env.TWINE_REPOSITORY_URL='${pythonPublishUrl2}';env.TWINE_USERNAME='AzureArtifacts';env.TWINE_PASSWORD=Buffer.from(process.env.${AZURE_PAT_VARIABLE},'base64').toString()}`;
1334
- }
1335
- if (registryKind !== "npm") {
1336
- return "";
1337
- }
1338
- return `if(hasPython){if(!process.env.${PYPI_TOKEN_VARIABLE}){console.error('This workspace has '+pythonCount+' Python package(s) to publish but ${PYPI_TOKEN_VARIABLE} is empty. Add a PyPI API token as a secret named ${PYPI_TOKEN_VARIABLE}, or remove the Python packages from release.projects.');process.exit(1)}env.TWINE_USERNAME='__token__';env.TWINE_PASSWORD=process.env.${PYPI_TOKEN_VARIABLE};env.TWINE_NON_INTERACTIVE='1'}`;
1339
- }
1340
- function nugetPublishEnvFragment(nugetFeedUrl2) {
1341
- return nugetFeedUrl2 ? `if(hasCsharp){${azurePatPreflight("csharpCount", "C#")}env.NUGET_PAT=Buffer.from(process.env.${AZURE_PAT_VARIABLE},'base64').toString()}` : "";
1342
- }
1343
1421
  function npmAuthEnvVariable(registryKind, variableReference) {
1344
1422
  return registryKind === "npm" ? ["NODE_AUTH_TOKEN", variableReference("NPM_TOKEN")] : [AZURE_PAT_VARIABLE, variableReference(AZURE_PAT_VARIABLE)];
1345
1423
  }
@@ -1483,66 +1561,27 @@ steps:
1483
1561
  ${npmAuthenticateStep} - script: npm ci
1484
1562
  displayName: Install dependencies${npmCiEnv}
1485
1563
 
1564
+ ${slotMarkers("after-install", " ")}
1565
+
1566
+ # Everything from here to the release is a command of the mnci CLI, a
1567
+ # devDependency of this workspace, so the version is the lockfile's and a
1568
+ # developer reproduces CI by running the same line. The guards that used to be
1569
+ # inline here are tested TypeScript in the CLI's ci-pipeline slice. Node, the
1570
+ # checkout and 'npm ci' stay above: Node must exist before 'npx' can run.
1571
+
1572
+ # The language toolchains the workspace needs: Python (ruff, pytest, build,
1573
+ # twine, and every project installed into one shared environment), Go (the
1574
+ # modules and a checksum-verified golangci-lint, put on PATH) and Flutter (the
1575
+ # pinned SDK, put on PATH, and one pub get). Each is skipped cleanly when the
1576
+ # workspace has no project in that language.
1577
+ - script: npx mnci ci setup
1578
+ displayName: Set up the language toolchains (Python, Go, Flutter)
1579
+
1486
1580
  # Fails ONLY on an advisory that has a published fix, at moderate or above;
1487
- # anything upstream has not fixed is printed and passes. See NPM_AUDIT_STEP's
1488
- # remarks for why that split replaced a warn-only step.
1489
- - script: ${NPM_AUDIT_STEP}
1490
- displayName: npm audit (fails on an actionable advisory)
1491
-
1492
- # Installs the fixed Python toolchain (ruff, pytest, build, twine, pip-audit)
1493
- # \u2014 written by 'mnci add' to requirements-dev.txt on the first Python
1494
- # project. Plain pip, no uv/Poetry: portable guard skips cleanly on a
1495
- # workspace with none.
1496
- - script: ${PYTHON_INSTALL_GUARD}
1497
- displayName: Install Python dependencies (ruff, pytest, build, twine, pip-audit)
1498
-
1499
- # Editable-installs every Python project into one shared environment, the
1500
- # pip-world counterpart of 'npm install' hoisting every workspace package
1501
- # into one root node_modules \u2014 so a project that imports an internal lib
1502
- # (normally vendored only at build time) can resolve that import at
1503
- # lint/test time too. Portable guard skips cleanly on a workspace with none.
1504
- - script: ${PYTHON_WORKSPACE_INSTALL_GUARD}
1505
- displayName: Install Python project dependencies (editable, workspace-wide)
1506
-
1507
- # Non-blocking, same reasoning as the npm audit step above. Runs after the
1508
- # workspace-wide install so it scans the workspace's actual dependency set,
1509
- # not just the fixed toolchain.
1510
- - script: ${PIP_AUDIT_GUARD}
1511
- displayName: pip-audit (non-blocking)
1512
-
1513
- # Go, if the workspace has any: hosted agents ship the toolchain, so only
1514
- # the module cache and the linter need seeding. Both guards skip cleanly on
1515
- # a workspace with no root go.mod.
1516
- - script: ${GO_MODULE_DOWNLOAD_GUARD}
1517
- displayName: Download Go module dependencies
1518
-
1519
- # golangci-lint is what the generated Go lint target actually runs (the
1520
- # plugin's own default is 'go fmt', which only reformats). The pinned,
1521
- # checksum-verified prebuilt release lands in GOPATH/bin (go install at the
1522
- # same version is the fallback), which is not on PATH by default on a hosted
1523
- # agent \u2014 so prepend it for every later step in the job.
1524
- - script: ${GOLANGCI_LINT_INSTALL_GUARD}
1525
- displayName: Install golangci-lint
1526
-
1527
- - script: ${GO_TOOL_PATH_AZURE}
1528
- displayName: Add Go tool bin to PATH
1529
-
1530
- # Flutter, if the workspace has any. Unlike Python and Go, hosted agents do
1531
- # NOT ship the Flutter SDK, so this installs it (shallow git clone at a
1532
- # pinned tag) and puts it on PATH. All three guards skip cleanly on a
1533
- # workspace with no root pubspec.yaml, and the install also skips when an
1534
- # SDK is already on PATH.
1535
- - script: ${FLUTTER_SDK_INSTALL_GUARD}
1536
- displayName: Install the Flutter SDK (${FLUTTER_SDK_VERSION})
1537
-
1538
- - script: ${FLUTTER_TOOL_PATH_AZURE}
1539
- displayName: Add the Flutter SDK to PATH
1540
-
1541
- # One command resolves EVERY Dart dependency, internal and external, for the
1542
- # whole workspace: the projects are pub workspace members, so this writes a
1543
- # single root pubspec.lock they all share.
1544
- - script: ${FLUTTER_PUB_GET_GUARD}
1545
- displayName: Resolve Dart dependencies (one pub get for the whole workspace)
1581
+ # anything upstream has not fixed is printed and passes. pip-audit only
1582
+ # reports: its output has no "a fix exists" field to draw that line with.
1583
+ - script: npx mnci ci audit
1584
+ displayName: Audit dependencies (fails on an actionable npm advisory)
1546
1585
 
1547
1586
  # .NET, if the workspace has any. Azure's 'condition:' expression language
1548
1587
  # has no glob function, so this script step detects C# projects first and
@@ -1558,29 +1597,19 @@ ${npmAuthenticateStep} - script: npm ci
1558
1597
  inputs:
1559
1598
  version: ${DOTNET_SDK_VERSION}
1560
1599
 
1561
- # Fails fast, with an unambiguous message, when a stale TypeScript project
1562
- # reference (or another sync generator's drift) was never synced+committed
1563
- # locally \u2014 sync.applyChanges (nx.json) only auto-applies interactively, so
1564
- # CI still needs its own explicit, early check rather than surfacing this as
1565
- # a confusing failure buried inside the build step below.
1566
- - script: npx nx sync:check
1567
- displayName: Verify the workspace is synced (run 'npx nx sync' locally and commit if this fails)
1568
-
1569
- # The one verify step, and deliberately the only one: affected projects on a
1570
- # pull request, EVERY project on anything else (a push to main included, so a
1571
- # release is always verified in full). Every fallback \u2014 no target branch, an
1572
- # unresolvable merge-base \u2014 takes the full path, because a run that verifies
1573
- # too little still reports green.
1574
- #
1575
- # 'npm run lint' is 'nx run-many -t lint', a strict subset of the targets
1576
- # below, so adding it back as its own step would only duplicate work \u2014 and on
1577
- # a pull request it would re-lint every project, discarding the point of this.
1578
- - script: ${nativeApps ? AFFECTED_OR_ALL_GUARD_WITHOUT_NATIVE : AFFECTED_OR_ALL_GUARD}
1579
- displayName: Verify (affected on a PR, every project on main)
1600
+ # Fails fast on a stale TypeScript project reference, then the one verify
1601
+ # step, and deliberately the only one: affected projects on a pull request,
1602
+ # EVERY project on anything else (a push to main included, so a release is
1603
+ # always verified in full). Every fallback takes the full path, because a run
1604
+ # that verifies too little still reports green. Apps that need a C toolchain
1605
+ # are left to the native job.
1606
+ - script: npx mnci ci verify
1607
+ displayName: Verify (sync check, then affected on a PR, every project on main)
1580
1608
 
1609
+ ${phaseStart("pack", " ")}
1581
1610
  # Pack every app into dist/drop/<type>-<name>.zip via each app's 'package'
1582
- # target. Portable guard: skip cleanly when the workspace has no apps yet.
1583
- - script: ${PACK_APPS_GUARD}
1611
+ # target; skipped cleanly when the workspace has no apps yet.
1612
+ - script: npx mnci ci pack
1584
1613
  displayName: Pack all apps (one zip per app -> dist/drop)
1585
1614
  condition: ${onMain}
1586
1615
 
@@ -1597,57 +1626,28 @@ ${npmAuthenticateStep} - script: npm ci
1597
1626
  - script: node -e "const fs=require('node:fs');const path=require('node:path');for(const f of fs.globSync('dist/drop/*.zip')){console.log('##vso[build.addbuildtag]'+path.basename(f,'.zip'))}"
1598
1627
  displayName: Tag the run per app (type-name)
1599
1628
  condition: ${onMain}
1600
-
1601
- # nx release resolves each package's current version from its git tag, and
1602
- # falls back to the (permanently stale, since git.commit is false) on-disk
1603
- # version when a tag cannot be resolved \u2014 which happens whenever this
1604
- # checkout lacks full history. This is currently guaranteed by fetchDepth 0
1605
- # above plus the explicit fetch below, but neither is expressed as a
1606
- # dependency anywhere else, so this makes it a hard, loud precondition
1607
- # instead of a silently wrong version discovered after release.
1608
- - script: ${SHALLOW_CLONE_GUARD}
1609
- displayName: Verify this is a full checkout (nx release needs the real tag history)
1610
- condition: ${onMain}
1611
- ${npmAuthPreflight(registryKind) ? `
1612
- # Prove npm will accept us BEFORE anything irreversible happens. nx release
1613
- # tags first and publishes last, so a rejected token leaves a version tagged
1614
- # with nothing on the registry - and that number is then skipped forever,
1615
- # because the next run resolves the current version from the newest tag.
1616
- - script: ${npmAuthPreflight(registryKind)}
1617
- displayName: Preflight \u2014 npm must accept the token before anything is tagged
1618
- condition: ${onMain}
1619
- env:
1620
- NODE_AUTH_TOKEN: $(NPM_TOKEN)
1621
- ` : ""}${pypiReleasePreflight(registryKind) ? `
1622
- # PyPI cannot be asked whether a token is good - the only endpoint that
1623
- # authenticates is the upload itself - so this checks the token's SHAPE and
1624
- # names the projects this release would have to CREATE. Project creation is
1625
- # rate limited per account, and a 429 arrives AFTER nx release has tagged.
1626
- - script: ${pypiReleasePreflight(registryKind)}
1627
- displayName: Preflight \u2014 name the PyPI projects this release would create
1628
- condition: ${onMain}
1629
- env:
1630
- PYPI_TOKEN: $(PYPI_TOKEN)
1631
- ` : ""}
1632
- # Version + tag + publish, in one release, for npm (packages/*), Python
1633
- # (python-packages/*) AND C# (packages/*/*.csproj) \u2014 conventional commits,
1634
- # tag-only push. Portable guard: nx release errors on an empty scope, so
1635
- # skip cleanly when there is nothing to release. When there are Python or
1636
- # C# packages and an Azure feed, twine/NuGet publish credentials are
1637
- # exported (raw PAT, decoded from the base64 variable).
1629
+ ${phaseEnd("pack", " ")}
1630
+
1631
+ ${slotMarkers("before-release", " ")}
1632
+
1633
+ ${phaseStart("release", " ")}
1634
+ # The release, in the order that keeps a failure safe: refuse a shallow
1635
+ # clone, prove the npm token and name the PyPI projects it would create (all
1636
+ # BEFORE anything is tagged), then version + tag + publish in one 'nx release'
1637
+ # for npm, Python, C# and VS Code, then push the tags explicitly (nx release's
1638
+ # own push never runs without a remote GitHub/GitLab Release configured, which
1639
+ # this pipeline never does). Which registry is in use is read from the 'mnci'
1640
+ # block of nx.json, not baked into this file. The secrets are mapped here, in
1641
+ # the one place a command cannot reach.
1638
1642
  #
1639
- # RELEASE_SPECIFIER (the pipeline variable above) overrides the bump nx
1640
- # would compute from conventional commits for THIS run. In a workspace with
1641
- # more than one releasable package, a bare keyword ('major'/'minor'/'patch')
1642
- # is unreliable \u2014 nx versions interdependent packages in two passes (a
1643
- # dependency-bump pass, then the specifier pass), and the second pass
1644
- # computes from a version cached before the first pass ran, silently
1645
- # landing back on the same patch bump either way. An exact version ('1.2.3')
1646
- # does not have this problem, since both passes apply it verbatim \u2014 prefer
1647
- # it whenever more than one package is releasable. The guard below fails
1648
- # the run rather than under-bumping silently: clear the variable back to
1649
- # '' once the override is no longer needed.
1650
- - script: ${releaseGuard(pythonPublishEnvFragment(pythonPublishUrl2, registryKind), nugetPublishEnvFragment(nugetFeedUrl2))}
1643
+ # RELEASE_SPECIFIER (the pipeline variable above) overrides the bump nx would
1644
+ # compute from conventional commits for THIS run: 'major', 'minor', 'patch', or
1645
+ # an exact version ('1.2.3'). With more than one releasable package a bare
1646
+ # keyword is unreliable (nx versions interdependent packages in two passes, and
1647
+ # the second computes from a version cached before the first), so the command
1648
+ # fails the run rather than under-bumping silently: prefer an exact version,
1649
+ # and clear the variable back to '' once the override is no longer needed.
1650
+ - script: npx mnci ci release
1651
1651
  displayName: Release \u2014 version, tag and publish (npm + Python + C# + VS Code)
1652
1652
  condition: ${onMain}
1653
1653
  env:
@@ -1656,15 +1656,9 @@ ${npmAuthPreflight(registryKind) ? `
1656
1656
  # A VS Code extension publishes with it; tools/vscode-extension.cjs skips the
1657
1657
  # Marketplace when it is unset, including when Azure leaves it as '$(VSCE_PAT)'.
1658
1658
  VSCE_PAT: $(VSCE_PAT)
1659
+ ${phaseEnd("release", " ")}
1659
1660
 
1660
- # nx release's own git push (release.git.push) is deliberately left off: it
1661
- # only runs when a remote GitHub/GitLab Release is configured, which this
1662
- # pipeline never does, so it would never push the tag the step above just
1663
- # created. Pushed explicitly, unconditionally (a no-op when nothing released)
1664
- # once tagging is guaranteed to have already happened.
1665
- - script: git push origin --tags
1666
- displayName: Push release tags (nx release's own push never runs without a remote Release configured)
1667
- condition: ${onMain}
1661
+ ${slotMarkers("after-release", " ")}
1668
1662
  `;
1669
1663
  if (!nativeApps) {
1670
1664
  return document;
@@ -1687,14 +1681,10 @@ ${npmAuthPreflight(registryKind) ? `
1687
1681
  ${npmAuthenticateStep} - script: npm ci
1688
1682
  displayName: Install dependencies${npmCiEnv}
1689
1683
 
1690
- - script: ${GO_MODULE_DOWNLOAD_GUARD}
1691
- displayName: Download Go module dependencies
1692
-
1693
- - script: ${GOLANGCI_LINT_INSTALL_GUARD}
1694
- displayName: Install golangci-lint
1695
-
1696
- - script: ${GO_TOOL_PATH_AZURE}
1697
- displayName: Add Go tool bin to PATH
1684
+ # The Go toolchain: the modules, and a checksum-verified golangci-lint put on
1685
+ # PATH. The other toolchains are skipped cleanly when absent.
1686
+ - script: npx mnci ci setup
1687
+ displayName: Set up the Go toolchain
1698
1688
 
1699
1689
  # A C compiler and pkg-config, which is all mnci can know a cgo app needs. Add the
1700
1690
  # -dev packages your app links to this line, for example libgtk-3-dev and
@@ -1704,7 +1694,7 @@ ${npmAuthenticateStep} - script: npm ci
1704
1694
  displayName: Install native prerequisites (Linux)
1705
1695
  condition: eq(variables['Agent.OS'], 'Linux')
1706
1696
 
1707
- - script: ${NATIVE_BUILD_COMMAND}
1697
+ - script: npx mnci ci native
1708
1698
  displayName: Lint, test, build and package the native apps
1709
1699
 
1710
1700
  - task: PublishBuildArtifacts@1
@@ -1795,66 +1785,27 @@ jobs:
1795
1785
  env:
1796
1786
  ${npmAuthName}: ${npmAuthValue}
1797
1787
 
1798
- # Fails ONLY on an advisory that has a published fix, at moderate or
1799
- # above; anything upstream has not fixed is printed and passes. See
1800
- # NPM_AUDIT_STEP's remarks for why that split replaced a warn-only step.
1801
- - run: ${NPM_AUDIT_STEP}
1802
- name: npm audit (fails on an actionable advisory)
1803
-
1804
- # Installs the fixed Python toolchain (ruff, pytest, build, twine,
1805
- # pip-audit) \u2014 written by 'mnci add' to requirements-dev.txt on the
1806
- # first Python project. Plain pip, no uv/Poetry: portable guard skips
1807
- # cleanly on a workspace with none.
1808
- - run: ${PYTHON_INSTALL_GUARD}
1809
- name: Install Python dependencies (ruff, pytest, build, twine, pip-audit)
1810
-
1811
- # Editable-installs every Python project into one shared environment, the
1812
- # pip-world counterpart of 'npm install' hoisting every workspace package
1813
- # into one root node_modules \u2014 so a project that imports an internal lib
1814
- # (normally vendored only at build time) can resolve that import at
1815
- # lint/test time too. Portable guard skips cleanly on a workspace with none.
1816
- - run: ${PYTHON_WORKSPACE_INSTALL_GUARD}
1817
- name: Install Python project dependencies (editable, workspace-wide)
1818
-
1819
- # Non-blocking, same reasoning as the npm audit step above. Runs after
1820
- # the workspace-wide install so it scans the workspace's actual
1821
- # dependency set, not just the fixed toolchain.
1822
- - run: ${PIP_AUDIT_GUARD}
1823
- name: pip-audit (non-blocking)
1824
-
1825
- # Go, if the workspace has any: hosted runners ship the toolchain, so
1826
- # only the module cache and the linter need seeding. Both guards skip
1827
- # cleanly on a workspace with no root go.mod.
1828
- - run: ${GO_MODULE_DOWNLOAD_GUARD}
1829
- name: Download Go module dependencies
1830
-
1831
- # golangci-lint is what the generated Go lint target actually runs (the
1832
- # plugin's own default is 'go fmt', which only reformats). The pinned,
1833
- # checksum-verified prebuilt release lands in GOPATH/bin (go install at the
1834
- # same version is the fallback), which is not on PATH by default on a
1835
- # hosted runner \u2014 so publish it for every later step in the job.
1836
- - run: ${GOLANGCI_LINT_INSTALL_GUARD}
1837
- name: Install golangci-lint
1838
-
1839
- - run: ${GO_TOOL_PATH_GITHUB}
1840
- name: Add Go tool bin to PATH
1841
-
1842
- # Flutter, if the workspace has any. Unlike Python and Go, hosted runners
1843
- # do NOT ship the Flutter SDK, so this installs it (shallow git clone at
1844
- # a pinned tag) and puts it on PATH. All three guards skip cleanly on a
1845
- # workspace with no root pubspec.yaml, and the install also skips when an
1846
- # SDK is already on PATH.
1847
- - run: ${FLUTTER_SDK_INSTALL_GUARD}
1848
- name: Install the Flutter SDK (${FLUTTER_SDK_VERSION})
1849
-
1850
- - run: ${FLUTTER_TOOL_PATH_GITHUB}
1851
- name: Add the Flutter SDK to PATH
1852
-
1853
- # One command resolves EVERY Dart dependency, internal and external, for
1854
- # the whole workspace: the projects are pub workspace members, so this
1855
- # writes a single root pubspec.lock they all share.
1856
- - run: ${FLUTTER_PUB_GET_GUARD}
1857
- name: Resolve Dart dependencies (one pub get for the whole workspace)
1788
+ ${slotMarkers("after-install", " ".repeat(6))}
1789
+
1790
+ # Everything from here to the release is a command of the mnci CLI, a
1791
+ # devDependency of this workspace, so the version is the lockfile's and a
1792
+ # developer reproduces CI by running the same line. The guards that used to be
1793
+ # inline here are tested TypeScript in the CLI's ci-pipeline slice. Node,
1794
+ # the checkout and 'npm ci' stay above: Node must exist before 'npx' can run.
1795
+
1796
+ # The language toolchains the workspace needs: Python (ruff, pytest, build,
1797
+ # twine, and every project installed into one shared environment), Go (the
1798
+ # modules and a checksum-verified golangci-lint, put on PATH) and Flutter (the
1799
+ # pinned SDK, put on PATH, and one pub get). Each is skipped cleanly when the
1800
+ # workspace has no project in that language.
1801
+ - run: npx mnci ci setup
1802
+ name: Set up the language toolchains (Python, Go, Flutter)
1803
+
1804
+ # Fails ONLY on an advisory that has a published fix, at moderate or above;
1805
+ # anything upstream has not fixed is printed and passes. pip-audit only
1806
+ # reports: its output has no "a fix exists" field to draw that line with.
1807
+ - run: npx mnci ci audit
1808
+ name: Audit dependencies (fails on an actionable npm advisory)
1858
1809
 
1859
1810
  # .NET, if the workspace has any. actions/setup-dotnet is the
1860
1811
  # maintained, cached installer GitHub itself ships, so this uses it
@@ -1866,30 +1817,19 @@ jobs:
1866
1817
  with:
1867
1818
  dotnet-version: ${DOTNET_SDK_VERSION}
1868
1819
 
1869
- # Fails fast, with an unambiguous message, when a stale TypeScript project
1870
- # reference (or another sync generator's drift) was never synced+committed
1871
- # locally \u2014 sync.applyChanges (nx.json) only auto-applies interactively, so
1872
- # CI still needs its own explicit, early check rather than surfacing this as
1873
- # a confusing failure buried inside the build step below.
1874
- - run: npx nx sync:check
1875
- name: Verify the workspace is synced (run 'npx nx sync' locally and commit if this fails)
1876
-
1877
- # The one verify step, and deliberately the only one: affected projects on
1878
- # a pull request, EVERY project on anything else (a push to main included,
1879
- # so a release is always verified in full). Every fallback \u2014 no target
1880
- # branch, an unresolvable merge-base \u2014 takes the full path, because a run
1881
- # that verifies too little still reports green.
1882
- #
1883
- # 'npm run lint' is 'nx run-many -t lint', a strict subset of the targets
1884
- # below, so adding it back as its own step would only duplicate work \u2014 and
1885
- # on a pull request it would re-lint every project, discarding the point
1886
- # of this.
1887
- - run: ${nativeApps ? AFFECTED_OR_ALL_GUARD_WITHOUT_NATIVE : AFFECTED_OR_ALL_GUARD}
1888
- name: Verify (affected on a PR, every project on main)
1820
+ # Fails fast on a stale TypeScript project reference, then the one verify
1821
+ # step, and deliberately the only one: affected projects on a pull request,
1822
+ # EVERY project on anything else (a push to main included, so a release is
1823
+ # always verified in full). Every fallback takes the full path, because a run
1824
+ # that verifies too little still reports green. Apps that need a C toolchain
1825
+ # are left to the native job.
1826
+ - run: npx mnci ci verify
1827
+ name: Verify (sync check, then affected on a PR, every project on main)
1889
1828
 
1829
+ ${phaseStart("pack", " ".repeat(6))}
1890
1830
  # Pack every app into dist/drop/<type>-<name>.zip via each app's 'package'
1891
- # target. Portable guard: skip cleanly when the workspace has no apps yet.
1892
- - run: ${PACK_APPS_GUARD}
1831
+ # target; skipped cleanly when the workspace has no apps yet.
1832
+ - run: npx mnci ci pack
1893
1833
  name: Pack all apps (one zip per app -> dist/drop)
1894
1834
  if: \${{ ${onMain} }}
1895
1835
 
@@ -1899,66 +1839,11 @@ jobs:
1899
1839
  name: drop
1900
1840
  path: dist/drop
1901
1841
  if-no-files-found: ignore
1842
+ ${phaseEnd("pack", " ".repeat(6))}
1902
1843
 
1903
- # nx release resolves each package's current version from its git tag,
1904
- # and falls back to the (permanently stale, since git.commit is false)
1905
- # on-disk version when a tag cannot be resolved \u2014 which happens
1906
- # whenever this checkout lacks full history. This is currently
1907
- # guaranteed by fetch-depth 0 above plus the explicit fetch below, but
1908
- # neither is expressed as a dependency anywhere else, so this makes it
1909
- # a hard, loud precondition instead of a silently wrong version
1910
- # discovered after release.
1911
- - run: ${SHALLOW_CLONE_GUARD}
1912
- name: Verify this is a full checkout (nx release needs the real tag history)
1913
- if: \${{ ${onMain} }}
1914
- ${npmAuthPreflight(registryKind) ? `
1915
- # Prove npm will accept us BEFORE anything irreversible happens. nx
1916
- # release tags first and publishes last, so a rejected token leaves a
1917
- # version tagged with nothing on the registry - and that number is then
1918
- # skipped forever, because the next run resolves the current version
1919
- # from the newest tag.
1920
- - run: ${npmAuthPreflight(registryKind)}
1921
- name: Preflight \u2014 npm must accept the token before anything is tagged
1922
- if: \${{ ${onMain} }}
1923
- env:
1924
- NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
1925
- ` : ""}${pypiReleasePreflight(registryKind) ? `
1926
- # PyPI cannot be asked whether a token is good - the only endpoint that
1927
- # authenticates is the upload itself - so this checks the token's SHAPE
1928
- # and names the projects this release would have to CREATE. Project
1929
- # creation is rate limited per account, and a 429 arrives AFTER nx
1930
- # release has tagged.
1931
- - run: ${pypiReleasePreflight(registryKind)}
1932
- name: Preflight \u2014 name the PyPI projects this release would create
1933
- if: \${{ ${onMain} }}
1934
- env:
1935
- PYPI_TOKEN: \${{ secrets.PYPI_TOKEN }}
1936
- ` : ""}
1937
- # Version + tag + publish, in one release, for npm (packages/*), Python
1938
- # (python-packages/*) AND C# (packages/*/*.csproj) \u2014 conventional
1939
- # commits, tag-only push. Portable guard: nx release errors on an empty
1940
- # scope, so skip cleanly when there is nothing to release. When there
1941
- # are Python or C# packages and an Azure feed, twine/NuGet publish
1942
- # credentials are exported (raw PAT, decoded from the base64 secret).
1943
- #
1944
- # RELEASE_SPECIFIER (repository variable, Settings -> Secrets and
1945
- # variables -> Actions -> Variables) overrides the bump nx would compute
1946
- # from conventional commits for THIS run: 'major', 'minor', 'patch', or
1947
- # an exact version ('1.2.3'). In a workspace with more than one
1948
- # releasable package, a bare keyword is unreliable \u2014 nx versions
1949
- # interdependent packages in two passes (a dependency-bump pass, then
1950
- # the specifier pass), and the second pass computes from a version
1951
- # cached before the first pass ran, silently landing back on the same
1952
- # patch bump either way. An exact version does not have this problem,
1953
- # since both passes apply it verbatim \u2014 prefer it whenever more than one
1954
- # package is releasable. The guard below fails the run rather than
1955
- # under-bumping silently: unset or clear the variable once the override
1956
- # is no longer needed.${githubReleases ? `
1957
- # This provider also creates a per-project GitHub Release (changelog
1958
- # generated from conventional commits since that project's last tag),
1959
- # which is why 'nx release' pushes the tag itself here \u2014 see
1960
- # releaseConfig's remarks for why that is safe on this Nx version and
1961
- # why every other provider combination keeps the explicit push step below.` : ""}
1844
+ ${slotMarkers("before-release", " ".repeat(6))}
1845
+
1846
+ ${phaseStart("release", " ".repeat(6))}
1962
1847
  # Signs in to Microsoft Entra ID as the Marketplace publishing identity (an
1963
1848
  # app registration with a federated credential for this repository's main
1964
1849
  # branch), so the release step publishes VS Code extensions with
@@ -1974,7 +1859,23 @@ ${npmAuthPreflight(registryKind) ? `
1974
1859
  tenant-id: \${{ vars.AZURE_TENANT_ID }}
1975
1860
  allow-no-subscriptions: true
1976
1861
 
1977
- - run: ${releaseGuard(pythonPublishEnvFragment(pythonPublishUrl2, registryKind), nugetPublishEnvFragment(nugetFeedUrl2))}
1862
+ # The release, in the order that keeps a failure safe: refuse a shallow
1863
+ # clone, prove the npm token and name the PyPI projects it would create (all
1864
+ # BEFORE anything is tagged), then version + tag + publish in one 'nx release'
1865
+ # for npm, Python, C# and VS Code, then attach a releasable Go app's zips and
1866
+ # push the tags. Which registry, and whether GitHub Releases are on, are read
1867
+ # from the 'mnci' block of nx.json, not baked into this file. The secrets are
1868
+ # mapped here, in the one place a command cannot reach.
1869
+ #
1870
+ # RELEASE_SPECIFIER (repository variable, Settings -> Secrets and
1871
+ # variables -> Actions -> Variables) overrides the bump nx would compute
1872
+ # from conventional commits for THIS run: 'major', 'minor', 'patch', or an
1873
+ # exact version ('1.2.3'). With more than one releasable package a bare
1874
+ # keyword is unreliable (nx versions interdependent packages in two passes,
1875
+ # and the second computes from a version cached before the first), so the
1876
+ # command fails the run rather than under-bumping silently: prefer an exact
1877
+ # version, and clear the variable once the override is no longer needed.
1878
+ - run: npx mnci ci release
1978
1879
  name: Release \u2014 version, tag${githubReleases ? ", publish and GitHub Release" : " and publish"} (npm + Python + C# + VS Code)
1979
1880
  if: \${{ ${onMain} }}
1980
1881
  env:
@@ -1985,28 +1886,12 @@ ${npmAuthPreflight(registryKind) ? `
1985
1886
  # tools/vscode-extension.cjs skips the Marketplace with neither.
1986
1887
  VSCE_AUTH: \${{ vars.AZURE_CLIENT_ID != '' && 'entra' || '' }}
1987
1888
  VSCE_PAT: \${{ secrets.VSCE_PAT }}${githubReleases ? `
1988
- GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}` : ""}
1989
- ${githubReleases ? `
1990
- # A releasable Go app (mnci add go-app --release) is versioned by its git
1991
- # tag, and its per-platform zips are attached to the GitHub Release the step
1992
- # above just created. Built here, after tagging, with VERSION set to the tag's
1993
- # version so the binary reports it. A run that released no Go app uploads
1994
- # nothing, and a workspace without one skips the step.
1995
- - run: node tools/go-app-release.cjs assets
1996
- name: Attach the per-platform zips to the GitHub Release (releasable Go apps)
1997
- if: \${{ ${onMain} && hashFiles('tools/go-app-release.cjs') != '' }}
1998
- env:
1999
- GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2000
- ` : `
2001
- # nx release's own git push (release.git.push) is deliberately left off: it
2002
- # only runs when a remote GitHub/GitLab Release is configured, which this
2003
- # workflow never does, so it would never push the tag the step above just
2004
- # created. Pushed explicitly, unconditionally (a no-op when nothing released)
2005
- # once tagging is guaranteed to have already happened.
2006
- - run: git push origin --tags
2007
- name: Push release tags (nx release's own push never runs without a remote Release configured)
2008
- if: \${{ ${onMain} }}
2009
- `}${nativeApps ? githubNativeJob(npmAuthName, npmAuthValue, onMain) : ""}`;
1889
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
1890
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}` : ""}
1891
+ ${phaseEnd("release", " ".repeat(6))}
1892
+
1893
+ ${slotMarkers("after-release", " ".repeat(6))}
1894
+ ${nativeApps ? githubNativeJob(npmAuthName, npmAuthValue) : ""}`;
2010
1895
  }
2011
1896
  var PIP_PROJECT_GLOBS = ["apps", "python-packages", "libs"];
2012
1897
  var PUB_PROJECT_GLOBS = ["apps", "packages", "libs"];
@@ -2326,8 +2211,9 @@ function applyOverlay(workspaceRoot, options, onProgress = () => {
2326
2211
  const nativeApps = hasNativeGoApp(workspaceRoot);
2327
2212
  if (options.ci === "azure" || options.ci === "both") {
2328
2213
  onProgress("azure-pipelines.yml \u2014 build, verify, pack and release");
2329
- writeFileEnsured(
2214
+ writePipeline(
2330
2215
  (0, import_node_path3.join)(workspaceRoot, "azure-pipelines.yml"),
2216
+ onProgress,
2331
2217
  azurePipelinesYaml(
2332
2218
  options.agent,
2333
2219
  options.variableGroup,
@@ -2341,8 +2227,9 @@ function applyOverlay(workspaceRoot, options, onProgress = () => {
2341
2227
  }
2342
2228
  if (options.ci === "github" || options.ci === "both") {
2343
2229
  onProgress(".github/workflows/ci.yml and dependabot.yml");
2344
- writeFileEnsured(
2230
+ writePipeline(
2345
2231
  (0, import_node_path3.join)(workspaceRoot, ".github/workflows/ci.yml"),
2232
+ onProgress,
2346
2233
  githubActionsYaml(options.agent, publishUrl, options.registry.kind, options.ci, nugetUrl, nativeApps)
2347
2234
  );
2348
2235
  writeFileEnsured((0, import_node_path3.join)(workspaceRoot, ".github/dependabot.yml"), dependabotConfig(workspaceRoot));
@@ -2373,7 +2260,7 @@ function runNative(workspaceRoot, dependencies = {}) {
2373
2260
  if (built !== 0) {
2374
2261
  return built;
2375
2262
  }
2376
- if (!isMainPush(environment) || !(0, import_node_fs5.existsSync)((0, import_node_path4.join)(workspaceRoot, "tools", "go-app-release.cjs"))) {
2263
+ if (host !== "github" || !isMainPush(environment) || !(0, import_node_fs5.existsSync)((0, import_node_path4.join)(workspaceRoot, "tools", "go-app-release.cjs"))) {
2377
2264
  return 0;
2378
2265
  }
2379
2266
  return inGroup("Attach this OS's zip to the GitHub Release", "node", ["tools/go-app-release.cjs", "assets", "--native"]);
@@ -2557,14 +2444,22 @@ function runReleaseCommand(workspaceRoot, registry, counts, environment, process
2557
2444
  }
2558
2445
  return processes.run("npx", ["nx", "release", ...extra, "--yes"]);
2559
2446
  }
2560
- function afterRelease(workspaceRoot, processes) {
2561
- if ((0, import_node_fs7.existsSync)((0, import_node_path6.join)(workspaceRoot, "tools", "go-app-release.cjs"))) {
2447
+ function nxPushesTags(workspaceRoot) {
2448
+ try {
2449
+ const nx = JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path6.join)(workspaceRoot, "nx.json"), "utf8"));
2450
+ return nx.release?.git?.push === true;
2451
+ } catch {
2452
+ return false;
2453
+ }
2454
+ }
2455
+ function afterRelease(workspaceRoot, processes, githubReleases) {
2456
+ if (githubReleases && (0, import_node_fs7.existsSync)((0, import_node_path6.join)(workspaceRoot, "tools", "go-app-release.cjs"))) {
2562
2457
  const assets = processes.run("node", ["tools/go-app-release.cjs", "assets"]);
2563
2458
  if (assets !== 0) {
2564
2459
  return assets;
2565
2460
  }
2566
2461
  }
2567
- return processes.run("git", ["push", "origin", "--tags"]);
2462
+ return githubReleases ? 0 : processes.run("git", ["push", "origin", "--tags"]);
2568
2463
  }
2569
2464
  async function runRelease(workspaceRoot, dependencies = {}) {
2570
2465
  const environment = dependencies.environment ?? process.env;
@@ -2605,7 +2500,7 @@ async function runRelease(workspaceRoot, dependencies = {}) {
2605
2500
  if (released !== 0) {
2606
2501
  return close(released);
2607
2502
  }
2608
- return close(afterRelease(workspaceRoot, processes));
2503
+ return close(afterRelease(workspaceRoot, processes, nxPushesTags(workspaceRoot)));
2609
2504
  }
2610
2505
 
2611
2506
  // src/ci-pipeline/setup.use-case.ts
@@ -7892,6 +7787,26 @@ function checkDeclarationSpecifiers(workspaceRoot) {
7892
7787
  ];
7893
7788
  });
7894
7789
  }
7790
+ function checkPipelinePhases(workspaceRoot) {
7791
+ const files = ["azure-pipelines.yml", ".github/workflows/ci.yml"];
7792
+ return files.filter((file) => fileExists((0, import_node_path31.join)(workspaceRoot, file))).flatMap((file) => {
7793
+ const { verifyActive, switchedOff } = inspectPipeline((0, import_node_fs25.readFileSync)((0, import_node_path31.join)(workspaceRoot, file), "utf8"));
7794
+ const verify = {
7795
+ check: `${file} runs the enforced verify phase`,
7796
+ ok: verifyActive,
7797
+ detail: `${file} has no active \`mnci ci verify\` call, so CI would build and release code nothing checked`,
7798
+ remedy: "run `mnci upgrade`, which restores it"
7799
+ };
7800
+ const warnings = switchedOff.map((phase) => ({
7801
+ check: `${file}: the ${phase} phase is switched off`,
7802
+ ok: true,
7803
+ warning: true,
7804
+ detail: `${file} does not run \`mnci ci ${phase}\``,
7805
+ remedy: `uncomment the ${phase} block in ${file} to run it again`
7806
+ }));
7807
+ return [verify, ...warnings];
7808
+ });
7809
+ }
7895
7810
  function collectFindings(workspaceRoot) {
7896
7811
  const nxJsonPath = (0, import_node_path31.join)(workspaceRoot, "nx.json");
7897
7812
  if (!fileExists(nxJsonPath)) {
@@ -7916,7 +7831,8 @@ function collectFindings(workspaceRoot) {
7916
7831
  checkSync(workspaceRoot),
7917
7832
  checkGoPluginRegistered(workspaceRoot),
7918
7833
  checkGoWorkInSync(workspaceRoot),
7919
- ...checkNativeApps(workspaceRoot)
7834
+ ...checkNativeApps(workspaceRoot),
7835
+ ...checkPipelinePhases(workspaceRoot)
7920
7836
  ].filter((finding) => finding !== void 0);
7921
7837
  }
7922
7838
  function runDoctor(workspaceRoot, options = {}) {
@@ -7930,7 +7846,12 @@ function runDoctor(workspaceRoot, options = {}) {
7930
7846
  return;
7931
7847
  }
7932
7848
  for (const finding of findings) {
7933
- if (finding.ok) {
7849
+ if (finding.ok && finding.warning === true) {
7850
+ logger.warn(`${finding.check} \u2014 ${finding.detail ?? ""}`);
7851
+ if (finding.remedy) {
7852
+ logger.info(` to change it: ${finding.remedy}`);
7853
+ }
7854
+ } else if (finding.ok) {
7934
7855
  logger.success(finding.check);
7935
7856
  } else {
7936
7857
  logger.error(`${finding.check} \u2014 ${finding.detail ?? "failed"}`);