@smoothbricks/cli 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +25 -13
  2. package/dist/cli.js +24 -0
  3. package/dist/github-ci/index.d.ts +17 -2
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +141 -11
  6. package/dist/github-ci/outputs.d.ts +22 -0
  7. package/dist/github-ci/outputs.d.ts.map +1 -0
  8. package/dist/github-ci/outputs.js +649 -0
  9. package/dist/monorepo/ci-workflow.js +1 -1
  10. package/dist/monorepo/managed-files.d.ts +1 -0
  11. package/dist/monorepo/managed-files.d.ts.map +1 -1
  12. package/dist/monorepo/managed-files.js +38 -0
  13. package/dist/monorepo/package-policy.d.ts.map +1 -1
  14. package/dist/monorepo/package-policy.js +26 -0
  15. package/dist/monorepo/packs/index.d.ts +3 -0
  16. package/dist/monorepo/packs/index.d.ts.map +1 -1
  17. package/dist/monorepo/packs/index.js +5 -2
  18. package/dist/monorepo/publish-workflow.d.ts +1 -0
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +239 -2
  21. package/dist/nx/index.d.ts +6 -0
  22. package/dist/nx/index.d.ts.map +1 -1
  23. package/dist/nx/index.js +55 -5
  24. package/dist/release/github-release.d.ts +3 -0
  25. package/dist/release/github-release.d.ts.map +1 -1
  26. package/dist/release/github-release.js +11 -0
  27. package/dist/release/index.d.ts +19 -1
  28. package/dist/release/index.d.ts.map +1 -1
  29. package/dist/release/index.js +140 -31
  30. package/dist/release/orchestration.d.ts +8 -1
  31. package/dist/release/orchestration.d.ts.map +1 -1
  32. package/dist/release/orchestration.js +38 -1
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
  34. package/managed/raw/tooling/direnv/setup-environment.ts +40 -3
  35. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  36. package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
  37. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  38. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  39. package/package.json +7 -4
  40. package/src/cli.ts +30 -4
  41. package/src/github-ci/index.test.ts +486 -0
  42. package/src/github-ci/index.ts +172 -13
  43. package/src/github-ci/outputs.ts +506 -0
  44. package/src/monorepo/__tests__/ci-workflow.test.ts +11 -0
  45. package/src/monorepo/__tests__/publish-workflow.test.ts +222 -1
  46. package/src/monorepo/ci-workflow.ts +1 -1
  47. package/src/monorepo/managed-files.test.ts +62 -0
  48. package/src/monorepo/managed-files.ts +41 -0
  49. package/src/monorepo/package-policy.test.ts +50 -1
  50. package/src/monorepo/package-policy.ts +28 -0
  51. package/src/monorepo/packs/index.test.ts +18 -1
  52. package/src/monorepo/packs/index.ts +7 -3
  53. package/src/monorepo/publish-workflow.ts +479 -2
  54. package/src/nx/index.test.ts +39 -0
  55. package/src/nx/index.ts +65 -5
  56. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  57. package/src/release/__tests__/github-release.test.ts +11 -0
  58. package/src/release/__tests__/orchestration.test.ts +57 -2
  59. package/src/release/__tests__/trust-publisher.test.ts +91 -2
  60. package/src/release/github-release.ts +12 -0
  61. package/src/release/index.ts +218 -40
  62. package/src/release/orchestration.ts +55 -3
@@ -1,7 +1,19 @@
1
1
  /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
2
 
3
+ import synchronizedPrettier from '@prettier/sync';
4
+ import {
5
+ LINUX_PLATFORM_TARGET_GLOBS,
6
+ MACOS_PLATFORM_TARGET_GLOBS,
7
+ } from '@smoothbricks/nx-plugin/workspace-config-policy';
8
+ import type { Options as PrettierOptions } from 'prettier';
3
9
  import { isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
4
10
 
11
+ const PUBLISH_WORKFLOW_FORMAT_OPTIONS = Object.freeze({
12
+ parser: 'yaml',
13
+ printWidth: 120,
14
+ proseWrap: 'always',
15
+ } satisfies PrettierOptions);
16
+
5
17
  export type PublishWorkflowBump = 'auto' | 'patch' | 'minor' | 'major' | 'prerelease';
6
18
  export type PublishWorkflowCondition = 'version-mode-not-none' | 'deploy-production' | 'failure' | 'always';
7
19
  export type PublishWorkflowNxTarget = 'build' | 'lint' | 'test';
@@ -42,6 +54,7 @@ export interface PublishWorkflowDefinitionOptions {
42
54
  deploy?: boolean;
43
55
  deployProvider?: 'cloudflare';
44
56
  repoName?: string;
57
+ platformTargetGlobs?: readonly string[];
45
58
  }
46
59
 
47
60
  export interface PublishWorkflowInputs {
@@ -256,8 +269,19 @@ function shouldRunStep(
256
269
  }
257
270
 
258
271
  export function renderPublishWorkflowYaml(options: PublishWorkflowDefinitionOptions = {}): string {
259
- const steps = definePublishWorkflow(options).steps;
260
- return `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
272
+ let workflow: string;
273
+ if (hasMacosPlatformTargets(options)) {
274
+ workflow = renderPlatformPublishWorkflowYaml(options);
275
+ } else if (hasLinuxPlatformTargets(options)) {
276
+ workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
277
+ definePublishWorkflow(options).steps,
278
+ options,
279
+ )}`;
280
+ } else {
281
+ const steps = definePublishWorkflow(options).steps;
282
+ workflow = `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
283
+ }
284
+ return synchronizedPrettier.format(workflow, PUBLISH_WORKFLOW_FORMAT_OPTIONS);
261
285
  }
262
286
 
263
287
  function renderPublishWorkflowHeader(options: PublishWorkflowDefinitionOptions): string {
@@ -475,6 +499,459 @@ function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
475
499
  return [` - name: ${step.name}`, ` if: ${githubExpression(condition)}`, ` run: ${run}`];
476
500
  }
477
501
 
502
+ function renderSingleJobPublishWorkflowSteps(
503
+ steps: PublishWorkflowStep[],
504
+ options: PublishWorkflowDefinitionOptions,
505
+ ): string {
506
+ const lines: string[] = [];
507
+ for (const step of steps) {
508
+ lines.push(...sectionLinesBefore(step));
509
+ lines.push(...commentLinesForStep(step));
510
+ lines.push(...yamlLinesForStep(step, options));
511
+ lines.push('');
512
+ if (step.kind === PublishWorkflowStepKind.Build) {
513
+ lines.push(
514
+ ' - name: 🐧 Build supplemental Linux targets',
515
+ ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
516
+ ` run: smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects "${githubExpression(
517
+ 'steps.version.outputs.projects',
518
+ )}"`,
519
+ '',
520
+ );
521
+ }
522
+ }
523
+ return `${lines.join('\n').trimEnd()}\n`;
524
+ }
525
+
526
+ function renderPlatformPublishWorkflowYaml(options: PublishWorkflowDefinitionOptions): string {
527
+ const deployInput =
528
+ options.deploy === true
529
+ ? `
530
+ deploy_environment:
531
+ type: choice
532
+ description: Deploy live systems after a successful publish.
533
+ options: [none, production]
534
+ default: none`
535
+ : '';
536
+ const steps = definePublishWorkflow(options).steps;
537
+ return `name: Publish
538
+
539
+ on:
540
+ workflow_dispatch:
541
+ inputs:
542
+ bump:
543
+ type: choice
544
+ description:
545
+ Use auto for conventional commits, or force a semver bump. Prerelease publishes to next; all others publish to
546
+ latest.
547
+ options: [auto, patch, minor, major, prerelease]
548
+ default: auto
549
+ dry_run:
550
+ type: boolean
551
+ description: Run release commands without writing versions, tags, publishes, or GitHub Releases.
552
+ default: false${deployInput}
553
+
554
+ permissions:
555
+ contents: read
556
+
557
+ concurrency:
558
+ group: release-${githubExpression('github.ref')}
559
+ cancel-in-progress: false
560
+
561
+ defaults:
562
+ run:
563
+ working-directory: tooling/direnv
564
+
565
+ jobs:
566
+ linux-release-candidate:
567
+ runs-on: ubuntu-latest
568
+ permissions:
569
+ contents: write
570
+ id-token: none
571
+ outputs:
572
+ mode: ${githubExpression('steps.version.outputs.mode')}
573
+ release-sha: ${githubExpression('steps.release-state.outputs.sha')}
574
+ env:
575
+ NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
576
+ GH_TOKEN: ${githubExpression('github.token')}
577
+ steps:
578
+ ${renderLinuxReleaseCandidateSteps(steps, options)}
579
+
580
+ macos-platform:
581
+ runs-on: macos-latest
582
+ permissions:
583
+ contents: read
584
+ id-token: none
585
+ env:
586
+ NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
587
+ GH_TOKEN: ${githubExpression('github.token')}
588
+ steps:
589
+ ${renderMacosPlatformSteps(options)}
590
+
591
+ publish-on-linux:
592
+ needs: [linux-release-candidate, macos-platform]
593
+ runs-on: ubuntu-latest
594
+ permissions:
595
+ contents: write
596
+ id-token: write
597
+ env:
598
+ NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
599
+ GH_TOKEN: ${githubExpression('github.token')}
600
+ steps:
601
+ ${renderFinalLinuxPublishSteps(options)}
602
+ `;
603
+ }
604
+
605
+ function renderLinuxReleaseCandidateSteps(
606
+ steps: PublishWorkflowStep[],
607
+ options: PublishWorkflowDefinitionOptions,
608
+ ): string {
609
+ const lines: string[] = [];
610
+ let stepNumber = 2;
611
+ for (const step of steps) {
612
+ if (
613
+ step.kind === PublishWorkflowStepKind.RepairPendingReleases ||
614
+ step.kind === PublishWorkflowStepKind.PublishRelease ||
615
+ step.kind === PublishWorkflowStepKind.DeployProduction ||
616
+ step.kind === PublishWorkflowStepKind.SaveNixDevenv
617
+ ) {
618
+ continue;
619
+ }
620
+ lines.push(...sectionLinesBefore(step));
621
+ if (step.kind === PublishWorkflowStepKind.Checkout) {
622
+ lines.push(' # Step 1: GitHub adds "Set up job" automatically', ` # Step ${stepNumber}`);
623
+ } else if (step.kind === PublishWorkflowStepKind.SetupDevenv) {
624
+ lines.push(
625
+ ` # Step ${stepNumber}. Composite action internals do not affect top-level job step`,
626
+ ' # anchors; update these comments if top-level steps move.',
627
+ );
628
+ } else {
629
+ lines.push(` # Step ${stepNumber}`);
630
+ }
631
+ if (step.kind === PublishWorkflowStepKind.Checkout) {
632
+ lines.push(
633
+ ` - name: ${step.name}`,
634
+ ' uses: actions/checkout@v6.0.2',
635
+ ' with:',
636
+ ` ref: ${githubExpression('github.sha')}`,
637
+ ' filter: blob:none',
638
+ ' fetch-depth: 0',
639
+ );
640
+ } else if (step.kind === PublishWorkflowStepKind.Build) {
641
+ lines.push(
642
+ ` - name: ${step.name}`,
643
+ ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
644
+ ' run:',
645
+ ` smoo github-ci nx-run-many --targets build --projects "${githubExpression(
646
+ 'steps.version.outputs.projects',
647
+ )}" --collect-outputs "${githubExpression('runner.temp')}/release-build-outputs"`,
648
+ );
649
+ } else {
650
+ lines.push(...yamlLinesForStep(step, options));
651
+ }
652
+ stepNumber += 1;
653
+ if (step.kind === PublishWorkflowStepKind.VersionRelease) {
654
+ lines.push(
655
+ '',
656
+ ` # Step ${stepNumber}`,
657
+ ' - name: 🔒 Capture candidate release SHA',
658
+ ' id: release-state',
659
+ ' run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"',
660
+ );
661
+ stepNumber += 1;
662
+ }
663
+ if (step.kind === PublishWorkflowStepKind.Build && hasLinuxPlatformTargets(options)) {
664
+ lines.push(
665
+ '',
666
+ ` # Step ${stepNumber}`,
667
+ ' - name: 🐧 Build supplemental Linux targets',
668
+ ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
669
+ ' run:',
670
+ ` smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(
671
+ ',',
672
+ )}" --projects "${githubExpression(
673
+ 'steps.version.outputs.projects',
674
+ )}" --collect-outputs "${githubExpression('runner.temp')}/linux-platform-outputs"`,
675
+ );
676
+ stepNumber += 1;
677
+ }
678
+ lines.push('');
679
+ }
680
+ lines.push(
681
+ ' # --- Candidate transfer --------------------------------------------------',
682
+ '',
683
+ ` # Step ${stepNumber++}`,
684
+ ' - name: 📦 Bundle validated release state',
685
+ ' run:',
686
+ ` mkdir -p "${githubExpression('runner.temp')}/publish-release-state" && git bundle create`,
687
+ ` "${githubExpression('runner.temp')}/publish-release-state/release-state.bundle" HEAD --tags && git rev-parse HEAD >`,
688
+ ` "${githubExpression('runner.temp')}/publish-release-state/release-head"`,
689
+ '',
690
+ ` # Step ${stepNumber++}`,
691
+ ' - name: 📤 Upload validated release state',
692
+ ' uses: actions/upload-artifact@v7.0.1',
693
+ ' with:',
694
+ ` name: publish-release-state-${githubExpression('github.run_id')}`,
695
+ ` path: ${githubExpression('runner.temp')}/publish-release-state`,
696
+ ' if-no-files-found: error',
697
+ ' retention-days: 1',
698
+ '',
699
+ ` # Step ${stepNumber++}`,
700
+ ' - name: 📤 Upload validated build outputs',
701
+ ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
702
+ ' uses: actions/upload-artifact@v7.0.1',
703
+ ' with:',
704
+ ` name: publish-release-outputs-${githubExpression('github.run_id')}`,
705
+ ` path: ${githubExpression('runner.temp')}/release-build-outputs`,
706
+ ' if-no-files-found: error',
707
+ ' retention-days: 1',
708
+ ' include-hidden-files: true',
709
+ );
710
+ if (hasLinuxPlatformTargets(options)) {
711
+ lines.push(
712
+ '',
713
+ ` # Step ${stepNumber++}`,
714
+ ' - name: 📤 Upload supplemental Linux outputs',
715
+ ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
716
+ ' uses: actions/upload-artifact@v7.0.1',
717
+ ' with:',
718
+ ` name: publish-linux-outputs-${githubExpression('github.run_id')}`,
719
+ ` path: ${githubExpression('runner.temp')}/linux-platform-outputs`,
720
+ ' if-no-files-found: error',
721
+ ' retention-days: 1',
722
+ ' include-hidden-files: true',
723
+ );
724
+ }
725
+ lines.push(
726
+ '',
727
+ ' # --- Cleanup ------------------------------------------------------------',
728
+ '',
729
+ ` # Step ${stepNumber}`,
730
+ ' - name: 🧹 Cleanup and cache Nix/devenv',
731
+ ` if: ${githubExpression('always()')}`,
732
+ ' uses: ./.github/actions/save-nix-devenv',
733
+ ' with:',
734
+ ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,
735
+ ` devenv-cache-hit: ${githubExpression('steps.setup.outputs.devenv-cache-hit')}`,
736
+ );
737
+ return lines.join('\n').trimEnd();
738
+ }
739
+
740
+ function renderMacosPlatformSteps(options: PublishWorkflowDefinitionOptions): string {
741
+ let stepNumber = 4;
742
+ const lines = [
743
+ ' # --- Setup --------------------------------------------------------------',
744
+ '',
745
+ ' # Step 1: GitHub adds "Set up job" automatically',
746
+ ' # Step 2',
747
+ ' - name: 📥 Checkout dispatch commit',
748
+ ' uses: actions/checkout@v6.0.2',
749
+ ' with:',
750
+ ` ref: ${githubExpression('github.sha')}`,
751
+ ' filter: blob:none',
752
+ ' fetch-depth: 0',
753
+ '',
754
+ ' # Step 3. Composite action internals do not affect top-level job step',
755
+ ' # anchors; update these comments if top-level steps move.',
756
+ ' - name: 🧱 Setup Nix/devenv',
757
+ ' id: setup',
758
+ ' uses: ./.github/actions/setup-devenv',
759
+ ];
760
+ if (isSmoothBricksCodebasePackageName(options.repoName)) {
761
+ lines.push(
762
+ '',
763
+ ` # Step ${stepNumber++}`,
764
+ ' - name: 🏗️ Build self-hosted smoo',
765
+ ' # SmoothBricks self-hosts smoo from source for release commands.',
766
+ ' run: nx build cli',
767
+ );
768
+ }
769
+ lines.push(
770
+ '',
771
+ ` # Step ${stepNumber++}`,
772
+ ' - name: 🍎 Build selected macOS and iOS release outputs',
773
+ ' run:',
774
+ ` smoo release build-platform-outputs --bump "${githubExpression(
775
+ 'inputs.bump',
776
+ )}" --ref "${githubExpression('github.sha')}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}" --output`,
777
+ ` "${githubExpression('runner.temp')}/macos-platform-outputs"`,
778
+ '',
779
+ ` # Step ${stepNumber++}`,
780
+ ' - name: 📤 Upload macOS platform outputs',
781
+ ' uses: actions/upload-artifact@v7.0.1',
782
+ ' with:',
783
+ ` name: publish-macos-outputs-${githubExpression('github.run_id')}`,
784
+ ` path: ${githubExpression('runner.temp')}/macos-platform-outputs`,
785
+ ' if-no-files-found: error',
786
+ ' retention-days: 1',
787
+ ' include-hidden-files: true',
788
+ '',
789
+ ' # --- Cleanup ------------------------------------------------------------',
790
+ '',
791
+ ` # Step ${stepNumber}`,
792
+ ' - name: 🧹 Cleanup and cache Nix/devenv',
793
+ ` if: ${githubExpression('always()')}`,
794
+ ' uses: ./.github/actions/save-nix-devenv',
795
+ ' with:',
796
+ ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,
797
+ ` devenv-cache-hit: ${githubExpression('steps.setup.outputs.devenv-cache-hit')}`,
798
+ );
799
+ return lines.join('\n').trimEnd();
800
+ }
801
+
802
+ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions): string {
803
+ const mode = 'needs.linux-release-candidate.outputs.mode';
804
+ let stepNumber = 2;
805
+ const lines = [
806
+ ' # --- Setup --------------------------------------------------------------',
807
+ '',
808
+ ' # Step 1: GitHub adds "Set up job" automatically',
809
+ ` # Step ${stepNumber++}`,
810
+ ' - name: 📥 Checkout dispatch commit',
811
+ ' uses: actions/checkout@v6.0.2',
812
+ ' with:',
813
+ ` ref: ${githubExpression('github.sha')}`,
814
+ ' filter: blob:none',
815
+ ' fetch-depth: 0',
816
+ '',
817
+ ` # Step ${stepNumber++}`,
818
+ ' - name: 📥 Download candidate artifacts',
819
+ ' uses: actions/download-artifact@v8.0.1',
820
+ ' with:',
821
+ ` pattern: publish-*-${githubExpression('github.run_id')}`,
822
+ ` path: ${githubExpression('runner.temp')}/publish-artifacts`,
823
+ ' merge-multiple: false',
824
+ '',
825
+ ` # Step ${stepNumber}. Composite action internals do not affect top-level job step`,
826
+ ' # anchors; update these comments if top-level steps move.',
827
+ ' - name: 🧱 Setup Nix/devenv',
828
+ ' id: setup',
829
+ ' uses: ./.github/actions/setup-devenv',
830
+ '',
831
+ ` # Step ${++stepNumber}`,
832
+ ' - name: 🤖 Configure release author',
833
+ ' run:',
834
+ ' git config user.name "github-actions[bot]" && git config user.email',
835
+ ' "41898282+github-actions[bot]@users.noreply.github.com"',
836
+ ];
837
+ stepNumber += 1;
838
+ if (isSmoothBricksCodebasePackageName(options.repoName)) {
839
+ lines.push(
840
+ '',
841
+ ` # Step ${stepNumber++}`,
842
+ ' - name: 🏗️ Build self-hosted smoo',
843
+ ' # SmoothBricks self-hosts smoo from source for release commands.',
844
+ ' run: nx build cli',
845
+ );
846
+ }
847
+ lines.push(
848
+ '',
849
+ ' # --- Repair -------------------------------------------------------------',
850
+ '',
851
+ ` # Step ${stepNumber++}`,
852
+ ' - name: 🧯 Repair pending releases',
853
+ ' run:',
854
+ ` smoo release repair-pending --ref "${githubExpression(
855
+ 'github.sha',
856
+ )}" --platform-outputs "${githubExpression(
857
+ 'runner.temp',
858
+ )}/publish-artifacts/publish-macos-outputs-${githubExpression('github.run_id')}/repairs" --dry-run`,
859
+ ` "${githubExpression('inputs.dry_run')}"`,
860
+ '',
861
+ ` # Step ${stepNumber++}`,
862
+ ' - name: ♻️ Restore validated release state',
863
+ ' run:',
864
+ ` git fetch "${githubExpression(
865
+ 'runner.temp',
866
+ )}/publish-artifacts/publish-release-state-${githubExpression('github.run_id')}/release-state.bundle" HEAD --tags && git reset`,
867
+ ` --hard "$(cat "${githubExpression(
868
+ 'runner.temp',
869
+ )}/publish-artifacts/publish-release-state-${githubExpression('github.run_id')}/release-head")"`,
870
+ );
871
+ lines.push(
872
+ '',
873
+ ' # --- Validation ---------------------------------------------------------',
874
+ '',
875
+ ` # Step ${stepNumber++}`,
876
+ ' - name: 📦 Apply verified Linux outputs',
877
+ ` if: ${githubExpression(`${mode} != 'none'`)}`,
878
+ ' run:',
879
+ ` smoo github-ci apply-outputs --source-sha "${githubExpression(
880
+ 'needs.linux-release-candidate.outputs.release-sha',
881
+ )}"`,
882
+ ` "${githubExpression('runner.temp')}/publish-artifacts/publish-release-outputs-${githubExpression(
883
+ 'github.run_id',
884
+ )}"`,
885
+ );
886
+ if (hasLinuxPlatformTargets(options)) {
887
+ lines.push(
888
+ ` "${githubExpression('runner.temp')}/publish-artifacts/publish-linux-outputs-${githubExpression(
889
+ 'github.run_id',
890
+ )}"`,
891
+ );
892
+ }
893
+ lines.push(
894
+ '',
895
+ ` # Step ${stepNumber++}`,
896
+ ' - name: 🍎 Apply verified macOS outputs',
897
+ ` if: ${githubExpression(`${mode} != 'none'`)}`,
898
+ ' run:',
899
+ ` smoo github-ci apply-outputs --source-sha "${githubExpression('github.sha')}"`,
900
+ ` "${githubExpression('runner.temp')}/publish-artifacts/publish-macos-outputs-${githubExpression(
901
+ 'github.run_id',
902
+ )}/current"`,
903
+ '',
904
+ ` # Step ${stepNumber++}`,
905
+ ` - name: ✅ Validate restored release (${githubExpression(mode)})`,
906
+ ` if: ${githubExpression(`${mode} != 'none'`)}`,
907
+ ' run: smoo monorepo validate',
908
+ '',
909
+ ' # --- Release ------------------------------------------------------------',
910
+ '',
911
+ ` # Step ${stepNumber++}`,
912
+ ` - name: 📦 Publish release (${githubExpression(mode)})`,
913
+ ' # smoo packs with Bun, then publishes tarballs with npm. Existing',
914
+ ' # packages must already exist on npm and use trusted publishing/OIDC.',
915
+ ' # Missing package names are bootstrapped locally before trust setup.',
916
+ ` run: smoo release publish --bump "${githubExpression('inputs.bump')}" --dry-run "${githubExpression(
917
+ 'inputs.dry_run',
918
+ )}"`,
919
+ );
920
+ if (options.deploy === true) {
921
+ lines.push(
922
+ '',
923
+ ` # Step ${stepNumber++}`,
924
+ ' - name: 🚀 Deploy production',
925
+ ' if:',
926
+ " ${{ needs.linux-release-candidate.outputs.mode != 'none' && inputs.deploy_environment == 'production' &&",
927
+ " inputs.dry_run != 'true' }}",
928
+ ...deployEnvLines(options),
929
+ ' run: smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
930
+ );
931
+ }
932
+ lines.push(
933
+ '',
934
+ ' # --- Cleanup ------------------------------------------------------------',
935
+ '',
936
+ ` # Step ${stepNumber}`,
937
+ ' - name: 🧹 Cleanup and cache Nix/devenv',
938
+ ` if: ${githubExpression('always()')}`,
939
+ ' uses: ./.github/actions/save-nix-devenv',
940
+ ' with:',
941
+ ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,
942
+ ` devenv-cache-hit: ${githubExpression('steps.setup.outputs.devenv-cache-hit')}`,
943
+ );
944
+ return lines.join('\n').trimEnd();
945
+ }
946
+
947
+ function hasMacosPlatformTargets(options: PublishWorkflowDefinitionOptions): boolean {
948
+ return MACOS_PLATFORM_TARGET_GLOBS.some((glob) => options.platformTargetGlobs?.includes(glob) === true);
949
+ }
950
+
951
+ function hasLinuxPlatformTargets(options: PublishWorkflowDefinitionOptions): boolean {
952
+ return LINUX_PLATFORM_TARGET_GLOBS.some((glob) => options.platformTargetGlobs?.includes(glob) === true);
953
+ }
954
+
478
955
  function githubExpression(expression: string): string {
479
956
  return ['$', '{{ ', expression, ' }}'].join('');
480
957
  }
@@ -7,7 +7,10 @@ import {
7
7
  nxShowProjectCommand,
8
8
  projectNamesFromNxShowProjectsOutput,
9
9
  projectNamesWithTarget,
10
+ projectRootFromNxProjectJson,
11
+ targetDependenciesFromNxProjectJson,
10
12
  targetNamesFromNxProjectJson,
13
+ targetOutputsFromNxProjectJson,
11
14
  } from './index.js';
12
15
 
13
16
  describe('Nx helper command construction', () => {
@@ -67,6 +70,42 @@ describe('Nx helper output formatting', () => {
67
70
  ]);
68
71
  });
69
72
 
73
+ it('extracts resolved roots, output declarations, and same-project target dependencies', () => {
74
+ const metadata = {
75
+ root: 'packages/native',
76
+ targets: {
77
+ 'build-macos': {
78
+ outputs: ['{projectRoot}/dist/*.dmg'],
79
+ dependsOn: ['^build', 'compile-macos', { target: 'package-macos', projects: 'self' }],
80
+ },
81
+ test: { outputs: [] },
82
+ },
83
+ };
84
+
85
+ expect(projectRootFromNxProjectJson(metadata)).toBe('packages/native');
86
+ expect(targetOutputsFromNxProjectJson(metadata)).toEqual(
87
+ new Map([
88
+ ['build-macos', ['{projectRoot}/dist/*.dmg']],
89
+ ['test', []],
90
+ ]),
91
+ );
92
+ expect(targetDependenciesFromNxProjectJson(metadata)).toEqual(
93
+ new Map([['build-macos', ['^build', 'compile-macos', 'package-macos']]]),
94
+ );
95
+ });
96
+
97
+ it('rejects unsupported cross-project object target dependencies', () => {
98
+ expect(() =>
99
+ targetDependenciesFromNxProjectJson({
100
+ targets: {
101
+ build: {
102
+ dependsOn: [{ target: 'build', projects: 'dependencies' }],
103
+ },
104
+ },
105
+ }),
106
+ ).toThrow('unsupported cross-project dependsOn');
107
+ });
108
+
70
109
  it('treats missing target metadata as an empty project', () => {
71
110
  expect(targetNamesFromNxProjectJson({ name: 'cli' })).toEqual([]);
72
111
  });
package/src/nx/index.ts CHANGED
@@ -7,9 +7,12 @@ import { decode, run } from '../lib/run.js';
7
7
 
8
8
  export interface ProjectTargets {
9
9
  project: string;
10
+ root?: string;
10
11
  targets: string[];
11
12
  buildDependsOn?: string[];
13
+ targetDependencies?: Map<string, string[]>;
12
14
  targetExecutors?: Map<string, string>;
15
+ targetOutputs?: Map<string, string[]>;
13
16
  targetScripts?: Map<string, string>;
14
17
  }
15
18
 
@@ -35,13 +38,41 @@ export function targetNamesFromNxProjectJson(value: unknown): string[] {
35
38
  return targets ? Object.keys(targets).sort((a, b) => a.localeCompare(b)) : [];
36
39
  }
37
40
 
41
+ export function projectRootFromNxProjectJson(value: unknown): string | undefined {
42
+ return isRecord(value) && typeof value.root === 'string' ? value.root : undefined;
43
+ }
44
+
38
45
  export function buildDependsOnFromNxProjectJson(value: unknown): string[] | undefined {
46
+ return targetDependenciesFromNxProjectJson(value).get('build');
47
+ }
48
+
49
+ export function targetDependenciesFromNxProjectJson(value: unknown): Map<string, string[]> {
39
50
  const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
40
- const build = targets ? recordProperty(targets, 'build') : null;
41
- if (!Array.isArray(build?.dependsOn) || !build.dependsOn.every((entry) => typeof entry === 'string')) {
42
- return undefined;
51
+ const dependencies = new Map<string, string[]>();
52
+ if (!targets) {
53
+ return dependencies;
54
+ }
55
+ for (const [targetName, target] of Object.entries(targets)) {
56
+ if (!isRecord(target) || !Array.isArray(target.dependsOn)) {
57
+ continue;
58
+ }
59
+ const entries: string[] = [];
60
+ for (const dependency of target.dependsOn) {
61
+ if (typeof dependency === 'string') {
62
+ entries.push(dependency);
63
+ continue;
64
+ }
65
+ if (!isRecord(dependency) || typeof dependency.target !== 'string') {
66
+ throw new Error(`Nx target ${targetName} has an invalid dependsOn entry.`);
67
+ }
68
+ if (dependency.projects !== undefined && dependency.projects !== 'self') {
69
+ throw new Error(`Nx target ${targetName} uses unsupported cross-project dependsOn for ${dependency.target}.`);
70
+ }
71
+ entries.push(dependency.target);
72
+ }
73
+ dependencies.set(targetName, entries);
43
74
  }
44
- return build.dependsOn;
75
+ return dependencies;
45
76
  }
46
77
 
47
78
  export function targetExecutorsFromNxProjectJson(value: unknown): Map<string, string> {
@@ -58,6 +89,10 @@ export function targetExecutorsFromNxProjectJson(value: unknown): Map<string, st
58
89
  return executors;
59
90
  }
60
91
 
92
+ export function targetOutputsFromNxProjectJson(value: unknown): Map<string, string[]> {
93
+ return targetStringArraysFromNxProjectJson(value, 'outputs');
94
+ }
95
+
61
96
  export function targetScriptsFromNxProjectJson(value: unknown): Map<string, string> {
62
97
  const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
63
98
  const scripts = new Map<string, string>();
@@ -76,6 +111,27 @@ export function targetScriptsFromNxProjectJson(value: unknown): Map<string, stri
76
111
  return scripts;
77
112
  }
78
113
 
114
+ function targetStringArraysFromNxProjectJson(value: unknown, property: string): Map<string, string[]> {
115
+ const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
116
+ const values = new Map<string, string[]>();
117
+ if (!targets) {
118
+ return values;
119
+ }
120
+ for (const [targetName, target] of Object.entries(targets)) {
121
+ if (!isRecord(target)) {
122
+ continue;
123
+ }
124
+ const entries = target[property];
125
+ if (Array.isArray(entries)) {
126
+ values.set(
127
+ targetName,
128
+ entries.filter((entry): entry is string => typeof entry === 'string'),
129
+ );
130
+ }
131
+ }
132
+ return values;
133
+ }
134
+
79
135
  export function formatProjectTargetLines(projects: ProjectTargets[]): string {
80
136
  return projects
81
137
  .flatMap((project) => project.targets.map((target) => `${project.project}:${target}`))
@@ -165,11 +221,15 @@ async function readProjectTarget(root: string, project: string): Promise<Project
165
221
  const command = nxShowProjectCommand(project);
166
222
  const result = await $`${command.command} ${command.args}`.cwd(root).quiet();
167
223
  const parsed: unknown = JSON.parse(decode(result.stdout));
224
+ const targetDependencies = targetDependenciesFromNxProjectJson(parsed);
168
225
  return {
169
226
  project,
227
+ root: projectRootFromNxProjectJson(parsed),
170
228
  targets: targetNamesFromNxProjectJson(parsed),
171
- buildDependsOn: buildDependsOnFromNxProjectJson(parsed),
229
+ buildDependsOn: targetDependencies.get('build'),
230
+ targetDependencies,
172
231
  targetExecutors: targetExecutorsFromNxProjectJson(parsed),
232
+ targetOutputs: targetOutputsFromNxProjectJson(parsed),
173
233
  targetScripts: targetScriptsFromNxProjectJson(parsed),
174
234
  };
175
235
  }