@mnci/cli 4.39.0 → 4.40.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/dist/cli.js CHANGED
@@ -492,6 +492,189 @@ function fileExists(path) {
492
492
  return (0, import_node_fs4.existsSync)(path);
493
493
  }
494
494
 
495
+ // src/pipeline-customization/legacy-pipeline-steps.algorithm.ts
496
+ var MNCI_STEP_NAMES = /* @__PURE__ */ new Set([
497
+ "Add Go tool bin to PATH",
498
+ "Add the Flutter SDK to PATH",
499
+ "Attach HEAD and fetch refs",
500
+ "Attach HEAD to the source branch",
501
+ "Attach the per-platform zips to the GitHub Release (releasable Go apps)",
502
+ "Attach this OS's zip to the GitHub Release (releasable native apps)",
503
+ "Audit dependencies (fails on an actionable npm advisory)",
504
+ "Authenticate npm against the feed (build identity, no PAT)",
505
+ "Authenticate npm registry",
506
+ "Build all deployables",
507
+ "Cache npm packages",
508
+ "Check formatting (run 'npm run format' locally to fix)",
509
+ "Detect .NET projects",
510
+ "Download Go module dependencies",
511
+ "Ensure the function-apps folder exists",
512
+ "Fetch branches and release tags",
513
+ "Install Azure Functions Core Tools",
514
+ "Install dependencies",
515
+ "Install golangci-lint",
516
+ "Install native prerequisites (Linux)",
517
+ "Install Python dependencies (ruff, pytest, build, twine)",
518
+ "Install Python dependencies (ruff, pytest, build, twine, pip-audit)",
519
+ "Install Python project dependencies (editable, workspace-wide)",
520
+ "Lint",
521
+ "Lint, test and build affected projects",
522
+ "Lint, test and build affected projects (PR)",
523
+ "Lint, test and build everything",
524
+ "Lint, test, build and package the native apps",
525
+ "Lint, typecheck, test and build everything",
526
+ "npm audit (fails on an actionable advisory)",
527
+ "npm audit (non-blocking)",
528
+ "Pack all apps (one zip per app -> dist/drop)",
529
+ "Package function apps",
530
+ "Pin npm to the major mnci verifies against",
531
+ "pip-audit (non-blocking)",
532
+ "Preflight \u2014 name the PyPI projects this release would create",
533
+ "Preflight \u2014 npm must accept the token before anything is tagged",
534
+ "Publish function app packages",
535
+ "Publish packages and tag main",
536
+ "Publish Python packages (uv)",
537
+ "Publish the drop (one zip per app)",
538
+ "Push release tags (nx release's own push never runs without a remote Release configured)",
539
+ "Release (version from commits, tag-only, publish)",
540
+ "Resolve Dart dependencies (one pub get for the whole workspace)",
541
+ "Set the git identity used for release tags",
542
+ "Set up the Go toolchain",
543
+ "Set up the language toolchains (Python, Go, Flutter)",
544
+ "Sign in to Microsoft Entra ID (VS Code Marketplace)",
545
+ "Tag the run per app (type-name)",
546
+ "Test and build everything",
547
+ "Verify the workspace is synced (run 'npx nx sync' locally and commit if this fails)",
548
+ "Verify this is a full checkout (nx release needs the real tag history)"
549
+ ]);
550
+ var MNCI_STEP_NAME_PATTERNS = [
551
+ /^Install the (\.NET SDK|Flutter SDK)/,
552
+ /^Release — version, tag/,
553
+ /^Verify \(/,
554
+ /^Publish this OS's native zips/
555
+ ];
556
+ var MNCI_USES = ["actions/checkout@", "actions/setup-node@", "actions/setup-dotnet@", "actions/upload-artifact@", "azure/login@"];
557
+ var MNCI_TASKS = ["UseNode@", "Cache@", "PublishBuildArtifacts@", "UseDotNet@", "npmAuthenticate@"];
558
+ var MNCI_COMMANDS = ["npm ci", "npm install -g npm@", "git fetch --all", 'git config user.name "github-actions', "git checkout -B", "npx mnci ci", "npx nx sync"];
559
+ function findFirstStepList(lines) {
560
+ const keyAt = lines.findIndex((line) => line.trim() === "steps:");
561
+ if (keyAt === -1) {
562
+ return void 0;
563
+ }
564
+ const firstItem = lines.findIndex((line, index) => index > keyAt && /^\s*- /.test(line));
565
+ if (firstItem === -1) {
566
+ return void 0;
567
+ }
568
+ const indent = lines[firstItem].length - lines[firstItem].trimStart().length;
569
+ const itemStart = " ".repeat(indent) + "- ";
570
+ const steps = [];
571
+ let pending = [];
572
+ let current;
573
+ for (let index = firstItem; index < lines.length; index++) {
574
+ const line = lines[index];
575
+ const lineIndent = line.length - line.trimStart().length;
576
+ const isBlank = line.trim() === "";
577
+ const isComment = line.trimStart().startsWith("#");
578
+ if (line.startsWith(itemStart)) {
579
+ current = { lead: pending, body: [line] };
580
+ steps.push(current);
581
+ pending = [];
582
+ } else if (isBlank || isComment && lineIndent <= indent) {
583
+ pending.push(line);
584
+ } else if (current && lineIndent > indent) {
585
+ current.body.push(...pending, line);
586
+ pending = [];
587
+ } else {
588
+ break;
589
+ }
590
+ }
591
+ return { steps, indent };
592
+ }
593
+ function topLevelFields(step) {
594
+ const fields = /* @__PURE__ */ new Map();
595
+ const keyIndent = step.body[0].length - step.body[0].trimStart().length + 2;
596
+ for (const [index, line] of step.body.entries()) {
597
+ const text = index === 0 ? line.trimStart().replace(/^- /, "") : line;
598
+ const lineIndent = index === 0 ? keyIndent : line.length - line.trimStart().length;
599
+ const trimmed = text.trimStart();
600
+ const colon = trimmed.indexOf(":");
601
+ const key = colon === -1 ? "" : trimmed.slice(0, colon);
602
+ if (lineIndent === keyIndent && /^[\w-]+$/.test(key) && !fields.has(key)) {
603
+ fields.set(key, trimmed.slice(colon + 1).trim().replaceAll(/^["']|["']$/g, ""));
604
+ }
605
+ }
606
+ return fields;
607
+ }
608
+ function isGeneratedStep(step) {
609
+ const fields = topLevelFields(step);
610
+ const name = fields.get("name") ?? fields.get("displayName");
611
+ if (name !== void 0 && (MNCI_STEP_NAMES.has(name) || MNCI_STEP_NAME_PATTERNS.some((pattern) => pattern.test(name)))) {
612
+ return true;
613
+ }
614
+ const uses = fields.get("uses");
615
+ if (uses !== void 0 && MNCI_USES.some((prefix) => uses.startsWith(prefix))) {
616
+ return true;
617
+ }
618
+ const task = fields.get("task");
619
+ if (task !== void 0 && MNCI_TASKS.some((prefix) => task.startsWith(prefix))) {
620
+ return true;
621
+ }
622
+ if (fields.has("checkout")) {
623
+ return true;
624
+ }
625
+ const command = fields.get("run") ?? fields.get("script");
626
+ return command !== void 0 && MNCI_COMMANDS.some((prefix) => command.startsWith(prefix));
627
+ }
628
+ function describeStep(step) {
629
+ const fields = topLevelFields(step);
630
+ return fields.get("name") ?? fields.get("displayName") ?? (fields.get("run") ?? fields.get("script") ?? step.body[0].trim()).slice(0, 60);
631
+ }
632
+ function landmark(step) {
633
+ const name = topLevelFields(step).get("name") ?? topLevelFields(step).get("displayName") ?? "";
634
+ if (/^(?:Verify \(|Lint|Test and build|Typecheck)/.test(name)) {
635
+ return "verify";
636
+ }
637
+ if (name.startsWith("Pack all apps")) {
638
+ return "pack";
639
+ }
640
+ if (/^(?:Release|Publish packages and tag)/.test(name)) {
641
+ return "release";
642
+ }
643
+ return void 0;
644
+ }
645
+ function readLegacyPipeline(lines) {
646
+ const reading = { slots: /* @__PURE__ */ new Map(), kept: [], hasPack: false, hasRelease: false };
647
+ const list = findFirstStepList(lines);
648
+ if (!list) {
649
+ return reading;
650
+ }
651
+ const marks = list.steps.map((step) => landmark(step));
652
+ const gate = marks.indexOf("verify");
653
+ const release = marks.indexOf("release");
654
+ reading.hasPack = marks.includes("pack");
655
+ reading.hasRelease = release !== -1;
656
+ for (const [index, step] of list.steps.entries()) {
657
+ if (isGeneratedStep(step)) {
658
+ continue;
659
+ }
660
+ let slot = "after-install";
661
+ if (release !== -1 && index > release) {
662
+ slot = "after-release";
663
+ } else if (gate !== -1 && index > gate) {
664
+ slot = "before-release";
665
+ }
666
+ const lines_ = [...step.lead, ...step.body].map((line) => line.startsWith(" ".repeat(list.indent)) ? line.slice(list.indent) : line.trimStart());
667
+ const existing = reading.slots.get(slot) ?? [];
668
+ reading.slots.set(slot, [...existing, ...existing.length > 0 ? [""] : [], ...trimBlank(lines_)]);
669
+ reading.kept.push(describeStep(step));
670
+ }
671
+ return reading;
672
+ }
673
+ function trimBlank(lines) {
674
+ const first = lines.findIndex((line) => line.trim() !== "");
675
+ return first === -1 ? [] : lines.slice(first);
676
+ }
677
+
495
678
  // src/pipeline-customization/pipeline-markers.algorithm.ts
496
679
  var CHOOSABLE_PHASES = ["pack", "release"];
497
680
  var SLOT_BEGIN = /^(\s*)# mnci:slot (\S+)/;
@@ -597,6 +780,31 @@ function inspectPipeline(text) {
597
780
  };
598
781
  }
599
782
 
783
+ // src/pipeline-customization/migrate-legacy-pipeline.use-case.ts
784
+ function migrateLegacyPipeline(legacy, generated) {
785
+ const reading = readLegacyPipeline(legacy.split("\n"));
786
+ const off = [];
787
+ if (reading.kept.length > 0 || reading.hasPack || reading.hasRelease) {
788
+ if (!reading.hasPack) {
789
+ off.push("pack");
790
+ }
791
+ if (!reading.hasRelease) {
792
+ off.push("release");
793
+ }
794
+ }
795
+ const text = commentOutPhases(injectSlots(generated, reading.slots), off);
796
+ const notes = [
797
+ "This pipeline was written before mnci kept user slots, so it was regenerated. Edits to the steps mnci generated, and anything outside the step list (triggers, variables, extra jobs), are in git history."
798
+ ];
799
+ if (reading.kept.length > 0) {
800
+ notes.push(`Kept ${reading.kept.length} step${reading.kept.length === 1 ? "" : "s"} of your own in the new slots: ${reading.kept.join("; ")}. Check their order.`);
801
+ }
802
+ for (const phase of off) {
803
+ notes.push(`The old pipeline had no ${phase} step, so the ${phase} phase is switched off.`);
804
+ }
805
+ return { text, notes };
806
+ }
807
+
600
808
  // src/pipeline-customization/merge-pipeline.use-case.ts
601
809
  function mergePipeline(existing, generated) {
602
810
  if (existing === void 0) {
@@ -604,10 +812,7 @@ function mergePipeline(existing, generated) {
604
812
  }
605
813
  const normalized = existing.replaceAll("\r\n", "\n");
606
814
  if (!/^\s*# mnci:(?:slot|phase) /m.test(normalized)) {
607
- return {
608
- text: generated,
609
- 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.']
610
- };
815
+ return migrateLegacyPipeline(normalized, generated);
611
816
  }
612
817
  const off = switchedOffPhases(normalized);
613
818
  const text = commentOutPhases(injectSlots(generated, extractSlots(normalized)), off);
@@ -805,7 +1010,19 @@ function releaseConfig(ci) {
805
1010
  //
806
1011
  // A workspace that DOES want the lock file refreshed should commit the
807
1012
  // bump too (`git.commit: true`), at which point this can be dropped.
808
- versionActionsOptions: { skipLockFileUpdate: true }
1013
+ versionActionsOptions: { skipLockFileUpdate: true },
1014
+ // OFF, because under this tag-only model it can only ever refuse a release.
1015
+ //
1016
+ // With it on, nx will not release a package whose internal dependency range
1017
+ // cannot absorb the bump, and `git.commit: false` means the manifests on disk
1018
+ // stay at the scaffold version for ever, so the range is always the one that
1019
+ // was written then. Packages start at 0.0.1 and `npm install <pkg> -w <other>`
1020
+ // writes `^0.0.1`, which on a 0.0.x version matches that version alone: the
1021
+ // first bump to 0.0.2 is outside it, and the release dies in the version phase,
1022
+ // before anything is tagged, with lint, typecheck, test and build all green
1023
+ // (the release runs on the default branch only, so nothing else ever tries it).
1024
+ // Measured on a fresh workspace of two interdependent packages (#345).
1025
+ preserveMatchingDependencyRanges: false
809
1026
  },
810
1027
  changelog: githubReleases ? {
811
1028
  workspaceChangelog: false,
@@ -7959,6 +8176,17 @@ function checkNativeBuildConfig(workspaceRoot) {
7959
8176
  remedy: "fix the entry in the mnci block of nx.json, then run `mnci upgrade`"
7960
8177
  }];
7961
8178
  }
8179
+ function checkDependencyRangesNotPreserved(nxJson) {
8180
+ if (nxJson.release === void 0) {
8181
+ return void 0;
8182
+ }
8183
+ return {
8184
+ check: "nx release does not preserve matching dependency ranges",
8185
+ ok: nxJson.release.version?.preserveMatchingDependencyRanges === false,
8186
+ detail: "release.version.preserveMatchingDependencyRanges is not false, so the first release of interdependent packages fails in the version phase, before anything is tagged",
8187
+ remedy: "run `mnci upgrade`, or set release.version.preserveMatchingDependencyRanges to false in nx.json"
8188
+ };
8189
+ }
7962
8190
  function collectFindings(workspaceRoot) {
7963
8191
  const nxJsonPath = (0, import_node_path32.join)(workspaceRoot, "nx.json");
7964
8192
  if (!fileExists(nxJsonPath)) {
@@ -7985,7 +8213,8 @@ function collectFindings(workspaceRoot) {
7985
8213
  checkGoWorkInSync(workspaceRoot),
7986
8214
  ...checkNativeApps(workspaceRoot),
7987
8215
  ...checkPipelinePhases(workspaceRoot),
7988
- ...checkNativeBuildConfig(workspaceRoot)
8216
+ ...checkNativeBuildConfig(workspaceRoot),
8217
+ checkDependencyRangesNotPreserved(nxJson)
7989
8218
  ].filter((finding) => finding !== void 0);
7990
8219
  }
7991
8220
  function runDoctor(workspaceRoot, options = {}) {