@smoothbricks/cli 0.10.2 → 0.10.4

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 (133) hide show
  1. package/README.md +31 -23
  2. package/dist/bun/preload.d.ts +13 -0
  3. package/dist/bun/preload.d.ts.map +1 -0
  4. package/dist/bun/preload.js +12 -0
  5. package/dist/cli.js +36 -4
  6. package/dist/generate/index.d.ts.map +1 -1
  7. package/dist/generate/index.js +0 -6
  8. package/dist/github-ci/index.d.ts +17 -2
  9. package/dist/github-ci/index.d.ts.map +1 -1
  10. package/dist/github-ci/index.js +192 -26
  11. package/dist/github-ci/outputs.d.ts +22 -0
  12. package/dist/github-ci/outputs.d.ts.map +1 -0
  13. package/dist/github-ci/outputs.js +648 -0
  14. package/dist/lib/cli-package.js +2 -2
  15. package/dist/lib/json.d.ts +118 -11
  16. package/dist/lib/json.d.ts.map +1 -1
  17. package/dist/lib/json.js +317 -29
  18. package/dist/lib/workspace.d.ts +7 -14
  19. package/dist/lib/workspace.d.ts.map +1 -1
  20. package/dist/lib/workspace.js +76 -82
  21. package/dist/monorepo/ci-workflow.d.ts +2 -0
  22. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  23. package/dist/monorepo/ci-workflow.js +28 -2
  24. package/dist/monorepo/git-config.js +2 -2
  25. package/dist/monorepo/index.d.ts +3 -1
  26. package/dist/monorepo/index.d.ts.map +1 -1
  27. package/dist/monorepo/index.js +6 -2
  28. package/dist/monorepo/lockfile.d.ts +13 -1
  29. package/dist/monorepo/lockfile.d.ts.map +1 -1
  30. package/dist/monorepo/lockfile.js +33 -21
  31. package/dist/monorepo/managed-files.d.ts +12 -0
  32. package/dist/monorepo/managed-files.d.ts.map +1 -1
  33. package/dist/monorepo/managed-files.js +86 -28
  34. package/dist/monorepo/package-policy.d.ts.map +1 -1
  35. package/dist/monorepo/package-policy.js +78 -45
  36. package/dist/monorepo/packed-manifest.d.ts +5 -2
  37. package/dist/monorepo/packed-manifest.d.ts.map +1 -1
  38. package/dist/monorepo/packed-manifest.js +16 -47
  39. package/dist/monorepo/packed-package.d.ts.map +1 -1
  40. package/dist/monorepo/packed-package.js +28 -16
  41. package/dist/monorepo/packs/index.d.ts +3 -0
  42. package/dist/monorepo/packs/index.d.ts.map +1 -1
  43. package/dist/monorepo/packs/index.js +18 -6
  44. package/dist/monorepo/publish-workflow.d.ts +1 -0
  45. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  46. package/dist/monorepo/publish-workflow.js +242 -3
  47. package/dist/monorepo/runtime.d.ts +15 -0
  48. package/dist/monorepo/runtime.d.ts.map +1 -1
  49. package/dist/monorepo/runtime.js +66 -12
  50. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  51. package/dist/monorepo/tool-validation.js +32 -23
  52. package/dist/monorepo/wrangler.d.ts.map +1 -1
  53. package/dist/monorepo/wrangler.js +13 -13
  54. package/dist/nx/index.d.ts +12 -4
  55. package/dist/nx/index.d.ts.map +1 -1
  56. package/dist/nx/index.js +86 -19
  57. package/dist/pr/index.d.ts.map +1 -1
  58. package/dist/pr/index.js +28 -32
  59. package/dist/release/candidates.d.ts +3 -2
  60. package/dist/release/candidates.d.ts.map +1 -1
  61. package/dist/release/candidates.js +4 -5
  62. package/dist/release/github-release.d.ts +3 -0
  63. package/dist/release/github-release.d.ts.map +1 -1
  64. package/dist/release/github-release.js +11 -0
  65. package/dist/release/index.d.ts +20 -1
  66. package/dist/release/index.d.ts.map +1 -1
  67. package/dist/release/index.js +221 -89
  68. package/dist/release/orchestration.d.ts +8 -1
  69. package/dist/release/orchestration.d.ts.map +1 -1
  70. package/dist/release/orchestration.js +38 -1
  71. package/dist/wrangler/prepare-env.d.ts +0 -1
  72. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  73. package/dist/wrangler/prepare-env.js +100 -51
  74. package/dist/wrangler/scaffold.d.ts.map +1 -1
  75. package/dist/wrangler/scaffold.js +12 -12
  76. package/managed/raw/git-format-staged.yml +19 -1
  77. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +6 -0
  78. package/managed/raw/tooling/direnv/merge-newer-pins.sh +3 -3
  79. package/managed/raw/tooling/direnv/setup-environment.ts +187 -19
  80. package/managed/raw/tooling/git-hooks/pre-commit.sh +0 -6
  81. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  82. package/managed/templates/github/actions/cache-node-modules/action.yml +6 -2
  83. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  84. package/managed/templates/github/actions/cache-ttsc-plugins/action.yml +19 -0
  85. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  86. package/managed/templates/github/actions/setup-devenv/action.yml +5 -0
  87. package/managed/templates/github/workflows/managed-files.yml +80 -0
  88. package/package.json +18 -4
  89. package/src/bun/preload.ts +12 -0
  90. package/src/cli.ts +49 -11
  91. package/src/generate/index.ts +0 -6
  92. package/src/github-ci/index.test.ts +486 -0
  93. package/src/github-ci/index.ts +220 -30
  94. package/src/github-ci/outputs.ts +505 -0
  95. package/src/lib/cli-package.ts +2 -2
  96. package/src/lib/json.ts +291 -32
  97. package/src/lib/workspace.ts +88 -100
  98. package/src/monorepo/__tests__/ci-workflow.test.ts +14 -3
  99. package/src/monorepo/__tests__/publish-workflow.test.ts +222 -1
  100. package/src/monorepo/ci-workflow.ts +28 -2
  101. package/src/monorepo/git-config.ts +2 -2
  102. package/src/monorepo/index.ts +6 -2
  103. package/src/monorepo/lockfile.test.ts +35 -36
  104. package/src/monorepo/lockfile.ts +48 -24
  105. package/src/monorepo/managed-files.test.ts +63 -0
  106. package/src/monorepo/managed-files.ts +94 -34
  107. package/src/monorepo/package-policy.test.ts +70 -51
  108. package/src/monorepo/package-policy.ts +104 -59
  109. package/src/monorepo/packed-manifest.ts +18 -52
  110. package/src/monorepo/packed-package.ts +43 -22
  111. package/src/monorepo/packs/index.test.ts +18 -1
  112. package/src/monorepo/packs/index.ts +20 -7
  113. package/src/monorepo/publish-workflow.ts +482 -3
  114. package/src/monorepo/runtime.test.ts +42 -3
  115. package/src/monorepo/runtime.ts +90 -12
  116. package/src/monorepo/tool-validation.ts +35 -25
  117. package/src/monorepo/wrangler.test.ts +11 -15
  118. package/src/monorepo/wrangler.ts +15 -15
  119. package/src/nx/index.test.ts +43 -0
  120. package/src/nx/index.ts +110 -23
  121. package/src/pr/index.ts +42 -36
  122. package/src/release/__tests__/candidates.test.ts +5 -4
  123. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  124. package/src/release/__tests__/github-release.test.ts +11 -0
  125. package/src/release/__tests__/orchestration.test.ts +57 -2
  126. package/src/release/__tests__/trust-publisher.test.ts +98 -2
  127. package/src/release/candidates.ts +10 -17
  128. package/src/release/github-release.ts +12 -0
  129. package/src/release/index.ts +321 -112
  130. package/src/release/orchestration.ts +55 -3
  131. package/src/wrangler/prepare-env.ts +75 -54
  132. package/src/wrangler/scaffold.test.ts +9 -12
  133. package/src/wrangler/scaffold.ts +13 -13
@@ -8,6 +8,8 @@ export enum CiWorkflowStepKind {
8
8
  Build = 'build',
9
9
  Lint = 'lint',
10
10
  UnitTests = 'unit-tests',
11
+ ManagedFilesCheck = 'managed-files-check',
12
+ ManagedFilesDispatch = 'managed-files-dispatch',
11
13
  Deploy = 'deploy',
12
14
  SaveNxCache = 'save-nx-cache',
13
15
  UploadTraceDbs = 'upload-trace-dbs',
@@ -37,6 +39,8 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
37
39
  { kind: CiWorkflowStepKind.Build, name: '🔨 Build' },
38
40
  { kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
39
41
  { kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
42
+ { kind: CiWorkflowStepKind.ManagedFilesCheck, name: '🩺 Check managed-file drift' },
43
+ { kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
40
44
  ];
41
45
  if (options.deploy) {
42
46
  steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Staging' });
@@ -64,7 +68,8 @@ ${renderYamlList(options.pushBranches, 6)}
64
68
  pull_request:
65
69
 
66
70
  permissions:
67
- actions: read
71
+ # actions:write lets the drift step dispatch the managed-files workflow.
72
+ actions: write
68
73
  contents: read
69
74
  statuses: write
70
75
 
@@ -161,6 +166,27 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
161
166
  return nxSmartStep(step, 'lint', 'Lint');
162
167
  case CiWorkflowStepKind.UnitTests:
163
168
  return nxSmartStep(step, 'test', 'Unit Tests');
169
+ case CiWorkflowStepKind.ManagedFilesCheck:
170
+ // The authoritative drift check is nearly free here (devenv is already
171
+ // up). --warn never fails the job and publishes the step output
172
+ // `drifted=<count>` for the dispatch step below.
173
+ return [
174
+ ` - name: ${step.name}`,
175
+ ' id: managed-drift',
176
+ ' if:',
177
+ " ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
178
+ ' github.event.repository.default_branch) }}',
179
+ ' run: smoo monorepo check --warn',
180
+ ];
181
+ case CiWorkflowStepKind.ManagedFilesDispatch:
182
+ // Dispatches the managed-files workflow, which maintains the persistent
183
+ // review PR. workflow_dispatch is exempt from GitHub's GITHUB_TOKEN
184
+ // recursion guard, so the default token suffices.
185
+ return [
186
+ ` - name: ${step.name}`,
187
+ " if: ${{ steps.managed-drift.outputs.drifted != '' && steps.managed-drift.outputs.drifted != '0' }}",
188
+ ' run: gh workflow run managed-files.yml --ref "$GITHUB_REF_NAME"',
189
+ ];
164
190
  case CiWorkflowStepKind.Deploy:
165
191
  return [
166
192
  ` - name: ${step.name}`,
@@ -181,7 +207,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
181
207
  ' path: |',
182
208
  ' .nx/cache',
183
209
  ' .nx/workspace-data/*.db*',
184
- ' key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}',
210
+ ' key: ${{ runner.os }}-${{ runner.arch }}-nx-db-v1-${{ github.sha }}',
185
211
  ];
186
212
  case CiWorkflowStepKind.UploadTraceDbs:
187
213
  return [
@@ -17,8 +17,8 @@ export async function applyWorkspaceGitConfig(root: string): Promise<void> {
17
17
  // Keep the newer runtime version pins on any merge (nvfetcher overlay +
18
18
  // devenv.lock) so a mirror sync's `git am --3way` never stalls on a version
19
19
  // conflict. Mapped by the managed .gitattributes (merge=smoo-newer-pins);
20
- // implemented in tooling/direnv/merge-newer-pins.sh. The next `devenv shell`
21
- // regenerates package.json fields from the resulting runtime.
20
+ // implemented in tooling/direnv/merge-newer-pins.sh. Runtime package.json
21
+ // pin repair is explicit via `smoo monorepo init --runtime-only`.
22
22
  await $`git config --local merge.smoo-newer-pins.name ${'keep the newer devenv/nvfetcher runtime pins'}`.cwd(root);
23
23
  await $`git config --local merge.smoo-newer-pins.driver ${'bash tooling/direnv/merge-newer-pins.sh %O %A %B %P'}`.cwd(
24
24
  root,
@@ -5,7 +5,7 @@ import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePac
5
5
  import { formatCommitMessage, validateCommitMessage } from './commit-msg.js';
6
6
  import { applyWorkspaceGitConfig } from './git-config.js';
7
7
  import { syncBunLockfileVersions } from './lockfile.js';
8
- import { applyManagedFiles, printResults } from './managed-files.js';
8
+ import { applyManagedFiles, printResults, warnOnManagedFileDrift } from './managed-files.js';
9
9
  import { listValidCommitScopes, validatePublicTags } from './package-policy.js';
10
10
  import { runInitPacks, runValidatePacks } from './packs/index.js';
11
11
  import { syncRootRuntimeVersions } from './runtime.js';
@@ -78,7 +78,11 @@ export function updateManagedFiles(root: string): void {
78
78
  printResults(applyManagedFiles(root, 'update'));
79
79
  }
80
80
 
81
- export function checkManagedFiles(root: string): void {
81
+ export function checkManagedFiles(root: string, options: { warn?: boolean } = {}): void {
82
+ if (options.warn === true) {
83
+ warnOnManagedFileDrift(root);
84
+ return;
85
+ }
82
86
  const results = applyManagedFiles(root, 'check');
83
87
  printResults(results);
84
88
  if (results.some((result) => result.action === 'drifted')) {
@@ -17,15 +17,6 @@ function git(root: string, args: string): string {
17
17
  return execSync(`git ${args}`, {
18
18
  cwd: root,
19
19
  encoding: 'utf8',
20
- env: {
21
- ...process.env,
22
- GIT_CONFIG_GLOBAL: '/dev/null',
23
- GIT_CONFIG_SYSTEM: '/dev/null',
24
- GIT_AUTHOR_NAME: 'fixture',
25
- GIT_AUTHOR_EMAIL: 'fixture@invalid',
26
- GIT_COMMITTER_NAME: 'fixture',
27
- GIT_COMMITTER_EMAIL: 'fixture@invalid',
28
- },
29
20
  stdio: ['pipe', 'pipe', 'pipe'],
30
21
  });
31
22
  }
@@ -40,6 +31,9 @@ function makeFixtureRepo(options: FixtureOptions): string {
40
31
  const root = mkdtempSync(join(tmpdir(), 'smoo-lockfile-'));
41
32
  cleanupRoots.push(root);
42
33
  git(root, 'init -q');
34
+ // Local identity only — CI runners have no global git user.
35
+ git(root, 'config user.name Test');
36
+ git(root, 'config user.email test@example.com');
43
37
  git(root, 'commit -q --allow-empty -m init');
44
38
  writeFileSync(
45
39
  join(root, 'package.json'),
@@ -56,8 +50,8 @@ function makeFixtureRepo(options: FixtureOptions): string {
56
50
 
57
51
  function writeFixturePackage(root: string, version: string): void {
58
52
  writeFileSync(
59
- join(root, 'packages', 'foo', 'package.json'),
60
- JSON.stringify({ name: '@fixture/foo', version, nx: { name: 'foo' } }),
53
+ join(root, 'packages/foo/package.json'),
54
+ `${JSON.stringify({ name: '@fixture/foo', version, private: false, nx: { name: 'foo' } }, null, 2)}\n`,
61
55
  );
62
56
  }
63
57
 
@@ -71,9 +65,9 @@ function writeFixtureLock(root: string, version: string): void {
71
65
  ' "": { "name": "fixture-root" },',
72
66
  ' "packages/foo": {',
73
67
  ' "name": "@fixture/foo",',
74
- ` "version": "${version}",`,
75
- ' },',
76
- ' },',
68
+ ` "version": "${version}"`,
69
+ ' }',
70
+ ' }',
77
71
  '}',
78
72
  '',
79
73
  ].join('\n'),
@@ -87,59 +81,64 @@ function lockVersionIn(root: string): string {
87
81
  }
88
82
 
89
83
  describe('bun.lock workspace version sync', () => {
90
- it('a bun-install revert of the release-synced version fails validation', () => {
91
- // The b3298fc4c shape: package.json holds the next prerelease, a stable tag
92
- // exists, and `bun install` rewrote the lockfile back to the prerelease.
84
+ it('install validate requires lockfile package.json including -next', () => {
93
85
  const root = makeFixtureRepo({
94
86
  packageVersion: '0.1.3-next.0',
95
- lockVersion: '0.1.3-next.0',
87
+ lockVersion: '0.1.2',
96
88
  stableTag: 'foo@0.1.2',
97
89
  });
98
90
  expect(validateBunLockfileVersions(root)).toBe(1);
99
91
  });
100
92
 
101
- it('sync restores the latest stable tag version and validation passes', () => {
93
+ it('install mode sync restores package.json -next for frozen CI', () => {
94
+ const root = makeFixtureRepo({
95
+ packageVersion: '0.1.3-next.0',
96
+ lockVersion: '0.1.2',
97
+ stableTag: 'foo@0.1.2',
98
+ });
99
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install' })).toBe(1);
100
+ expect(lockVersionIn(root)).toBe('0.1.3-next.0');
101
+ expect(validateBunLockfileVersions(root)).toBe(0);
102
+ });
103
+
104
+ it('publish mode rewrites unpublished -next to last stable tag for pack', () => {
102
105
  const root = makeFixtureRepo({
103
106
  packageVersion: '0.1.3-next.0',
104
107
  lockVersion: '0.1.3-next.0',
105
108
  stableTag: 'foo@0.1.2',
106
109
  });
107
- expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
108
- expect(lockVersionIn(root)).toBe('0.1.2');
110
+ // Install/CI is happy with -next matching package.json.
109
111
  expect(validateBunLockfileVersions(root)).toBe(0);
112
+ // Pre-publish rewrite embeds last stable for bun pm pack.
113
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'publish' })).toBe(1);
114
+ expect(lockVersionIn(root)).toBe('0.1.2');
110
115
  });
111
116
 
112
- it('a never-published prerelease package falls back to its manifest version', () => {
113
- // The new-workspace-package shape (e.g. cowshed): no stable tag exists yet.
117
+ it('never-published prerelease keeps package.json version in both modes', () => {
114
118
  const root = makeFixtureRepo({ packageVersion: '0.2.0-next.0', lockVersion: '0.2.0-next.0' });
115
119
  expect(validateBunLockfileVersions(root)).toBe(0);
116
- expect(syncBunLockfileVersions(root, { log: false })).toBe(0);
120
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install' })).toBe(0);
121
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'publish' })).toBe(0);
117
122
  });
118
123
 
119
- it('a stable manifest version is required verbatim in the lockfile', () => {
124
+ it('stable manifest version is required verbatim', () => {
120
125
  const root = makeFixtureRepo({ packageVersion: '0.1.0', lockVersion: '0.0.9' });
121
126
  expect(validateBunLockfileVersions(root)).toBe(1);
122
- expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
127
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install' })).toBe(1);
123
128
  expect(lockVersionIn(root)).toBe('0.1.0');
124
129
  });
125
130
 
126
131
  it('stage: true stages the healed bun.lock', () => {
127
132
  const root = makeFixtureRepo({
128
133
  packageVersion: '0.1.3-next.0',
129
- lockVersion: '0.1.3-next.0',
134
+ lockVersion: '0.1.2',
130
135
  stableTag: 'foo@0.1.2',
131
136
  });
132
- expect(syncBunLockfileVersions(root, { log: false, stage: true })).toBe(1);
137
+ expect(syncBunLockfileVersions(root, { log: false, mode: 'install', stage: true })).toBe(1);
133
138
  expect(git(root, 'diff --cached --name-only')).toContain('bun.lock');
134
139
  });
135
140
 
136
- it('stage: true leaves a clean lockfile unstaged', () => {
137
- const root = makeFixtureRepo({ packageVersion: '0.1.2', lockVersion: '0.1.2', stableTag: 'foo@0.1.2' });
138
- expect(syncBunLockfileVersions(root, { log: false, stage: true })).toBe(0);
139
- expect(git(root, 'diff --cached --name-only').trim()).toBe('');
140
- });
141
-
142
- it('property: after sync, validation always passes', () => {
141
+ it('property: after install-mode sync, validation always passes', () => {
143
142
  const version = fc
144
143
  .tuple(fc.nat({ max: 20 }), fc.nat({ max: 20 }), fc.nat({ max: 20 }), fc.option(fc.nat({ max: 5 })))
145
144
  .map(([major, minor, patch, next]) => `${major}.${minor}.${patch}${next === null ? '' : `-next.${next}`}`);
@@ -148,7 +147,7 @@ describe('bun.lock workspace version sync', () => {
148
147
  fc.property(version, version, (packageVersion, lockVersion) => {
149
148
  writeFixturePackage(root, packageVersion);
150
149
  writeFixtureLock(root, lockVersion);
151
- syncBunLockfileVersions(root, { log: false });
150
+ syncBunLockfileVersions(root, { log: false, mode: 'install' });
152
151
  return validateBunLockfileVersions(root) === 0;
153
152
  }),
154
153
  { numRuns: 25 },
@@ -5,27 +5,36 @@ import { escapeRegex, getWorkspacePackages } from '../lib/workspace.js';
5
5
 
6
6
  export interface SyncBunLockfileVersionsOptions {
7
7
  log?: boolean;
8
- /** `git add` bun.lock after rewriting it, for the pre-commit self-heal path. */
8
+ /** `git add` bun.lock when versions were resynced. */
9
9
  stage?: boolean;
10
+ /**
11
+ * `install` (default for validation): lockfile must match package.json.
12
+ * `publish`: rewrite unpublished prerelease package.json versions to the last
13
+ * stable git tag so `bun pm pack` embeds installable dependency versions.
14
+ */
15
+ mode?: 'install' | 'publish';
10
16
  }
11
17
 
12
- // Temporary Bun workaround. Delete this sync function, validateBunLockfileVersions,
13
- // the `smoo monorepo sync-bun-lockfile-versions` command, and the matching Nx
14
- // versionActions hook once supported Bun versions stop leaving workspace package
15
- // versions stale in bun.lock after manifest bumps. Until then, `bun pm pack`
16
- // rewrites `workspace:*` dependencies using those stale lockfile versions instead
17
- // of the current package.json versions. Track removal against:
18
+ // Temporary Bun workaround. Delete once supported Bun versions stop leaving
19
+ // workspace package versions stale in bun.lock after manifest bumps, and stop
20
+ // resolving `workspace:*` from the lockfile during `bun pm pack`:
18
21
  // - https://github.com/oven-sh/bun/issues/18906
19
22
  // - https://github.com/oven-sh/bun/issues/20477
20
23
  // - https://github.com/oven-sh/bun/issues/20829
21
24
  //
22
- // Any `bun install` rewrites the workspace versions from package.json, reverting
23
- // this sync between releases. The pre-commit hook therefore runs
24
- // `smoo monorepo sync-bun-lockfile-versions --stage` on every commit so drift is
25
- // healed at the commit that would otherwise carry it — validation must never
26
- // fail a committer for drift a routine `bun install` introduced.
25
+ // Two modes, because one lockfile serves two jobs:
26
+ //
27
+ // 1) install / frozen CI → lockfile package.json (including -next)
28
+ // 2) pre-publish pack → for package.json prereleases that were never published
29
+ // (post-release "prepare next"), lockfile entry := last
30
+ // stable tag so a releasing package does not embed an
31
+ // unpublished -next dependency version.
32
+ //
33
+ // Do not run publish-mode rewrite in day-to-day validate or pre-commit: bun install
34
+ // rewrites lockfile back to package.json and frozen CI then fails.
27
35
  export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVersionsOptions = {}): number {
28
36
  const log = options.log ?? true;
37
+ const mode = options.mode ?? 'publish';
29
38
  const lockfilePath = join(root, 'bun.lock');
30
39
  if (!existsSync(lockfilePath)) {
31
40
  throw new Error('bun.lock not found');
@@ -34,13 +43,7 @@ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVe
34
43
  let lockfile = readFileSync(lockfilePath, 'utf8');
35
44
  let updated = 0;
36
45
  for (const pkg of packages) {
37
- // If the package.json version is a prerelease (e.g. 0.2.2-next.0) it may
38
- // never have been published. Use the latest stable git tag version so
39
- // that `bun pm pack` writes an installable dependency range for consumers.
40
- const targetVersion = pkg.version.includes('-')
41
- ? (latestStableTagVersion(root, pkg.projectName) ?? pkg.version)
42
- : pkg.version;
43
-
46
+ const targetVersion = lockfileTargetVersion(root, pkg.projectName, pkg.version, mode);
44
47
  const relativePath = pkg.path.replaceAll('\\', '/');
45
48
  const escaped = escapeRegex(relativePath);
46
49
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
@@ -60,7 +63,10 @@ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVe
60
63
  }
61
64
  lockfile = lockfile.replace(pattern, `$1${targetVersion}$3`);
62
65
  if (log) {
63
- const suffix = targetVersion !== pkg.version ? ` (latest stable tag; package.json has ${pkg.version})` : '';
66
+ const suffix =
67
+ mode === 'publish' && targetVersion !== pkg.version
68
+ ? ` (latest stable tag; package.json has ${pkg.version})`
69
+ : '';
64
70
  console.log(`fix: ${relativePath}: ${lockVersion} -> ${targetVersion}${suffix}`);
65
71
  }
66
72
  updated++;
@@ -81,6 +87,7 @@ export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVe
81
87
  return updated;
82
88
  }
83
89
 
90
+ /** Install/CI invariant: bun.lock workspace versions match package.json. */
84
91
  export function validateBunLockfileVersions(root: string): number {
85
92
  const lockfilePath = join(root, 'bun.lock');
86
93
  if (!existsSync(lockfilePath)) {
@@ -91,10 +98,7 @@ export function validateBunLockfileVersions(root: string): number {
91
98
  const lockfile = readFileSync(lockfilePath, 'utf8');
92
99
  let failures = 0;
93
100
  for (const pkg of packages) {
94
- const targetVersion = pkg.version.includes('-')
95
- ? (latestStableTagVersion(root, pkg.projectName) ?? pkg.version)
96
- : pkg.version;
97
-
101
+ const targetVersion = pkg.version;
98
102
  const relativePath = pkg.path.replaceAll('\\', '/');
99
103
  const escaped = escapeRegex(relativePath);
100
104
  const pattern = new RegExp(`("${escaped}":\\s*\\{[^}]*"version":\\s*")([^"]+)(")`);
@@ -116,6 +120,26 @@ export function validateBunLockfileVersions(root: string): number {
116
120
  return failures;
117
121
  }
118
122
 
123
+ /**
124
+ * Version Bun should embed for a workspace dependency when packing for publish.
125
+ * Unpublished package.json prereleases (prepare-next) map to the last stable tag.
126
+ */
127
+ export function publishPackDependencyVersion(root: string, projectName: string, packageVersion: string): string {
128
+ return lockfileTargetVersion(root, projectName, packageVersion, 'publish');
129
+ }
130
+
131
+ function lockfileTargetVersion(
132
+ root: string,
133
+ projectName: string,
134
+ packageVersion: string,
135
+ mode: 'install' | 'publish',
136
+ ): string {
137
+ if (mode === 'install' || !packageVersion.includes('-')) {
138
+ return packageVersion;
139
+ }
140
+ return latestStableTagVersion(root, projectName) ?? packageVersion;
141
+ }
142
+
119
143
  function latestStableTagVersion(root: string, projectName: string): string | null {
120
144
  try {
121
145
  const output = execSync(`git tag --list '${projectName}@*' --sort=-v:refname`, {
@@ -1,16 +1,31 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are asserted literally. */
1
2
  import { describe, expect, it } from 'bun:test';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
2
6
  import fc from 'fast-check';
3
7
  import {
4
8
  extractInlineLocalBlocksForTest,
5
9
  INLINE_LOCAL_BEGIN,
6
10
  INLINE_LOCAL_END,
7
11
  LOCAL_SECTION_MARKER,
12
+ platformTargetGlobsForTest,
8
13
  reinsertInlineLocalBlocksForTest,
9
14
  splitLocalSectionForTest,
10
15
  } from './managed-files.js';
11
16
 
12
17
  const MANAGED = '# managed content\npath merge=driver\n';
13
18
 
19
+ const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..');
20
+ const ARCHITECTURE_SCOPED_PREFIX = '${{ runner.os }}-${{ runner.arch }}-';
21
+ const NODE_MODULES_CACHE_KEY = "${{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}";
22
+ const CACHE_ACTIONS = [
23
+ { name: 'cache-nix-devenv', osKeyLines: 6 },
24
+ { name: 'cache-node-modules', osKeyLines: 2 },
25
+ { name: 'cache-ttsc-plugins', osKeyLines: 2 },
26
+ { name: 'cache-nx', osKeyLines: 2 },
27
+ ] as const;
28
+
14
29
  describe('managed-file local sections', () => {
15
30
  it('content without a marker is entirely managed', () => {
16
31
  const { managed, localTail } = splitLocalSectionForTest(MANAGED);
@@ -139,3 +154,51 @@ describe('managed-file inline local blocks', () => {
139
154
  );
140
155
  });
141
156
  });
157
+
158
+ describe('managed publish platform discovery', () => {
159
+ it('returns canonical target families from resolved target names without leaking project names', () => {
160
+ const discovered = platformTargetGlobsForTest(['build', 'bundle-linux', 'package-macos', 'simulator-ios', 'test']);
161
+
162
+ expect(discovered).toEqual([...PLATFORM_TARGET_GLOBS]);
163
+ expect(discovered).not.toContain('native-app');
164
+ });
165
+
166
+ it('selects only supplemental Linux when resolved metadata has no Apple targets', () => {
167
+ expect(platformTargetGlobsForTest(['build', 'bundle-linux', 'test'])).toEqual([...LINUX_PLATFORM_TARGET_GLOBS]);
168
+ });
169
+
170
+ it('returns no platform families for ordinary Nx targets', () => {
171
+ expect(platformTargetGlobsForTest(['build', 'lint', 'test', 'typecheck'])).toEqual([]);
172
+ });
173
+ });
174
+
175
+ describe('managed cache actions', () => {
176
+ it('renders the checked-in action copies from their managed templates', async () => {
177
+ for (const action of CACHE_ACTIONS) {
178
+ const [template, generated] = await Promise.all([
179
+ readFile(
180
+ join(REPO_ROOT, 'packages', 'cli', 'managed', 'templates', 'github', 'actions', action.name, 'action.yml'),
181
+ 'utf8',
182
+ ),
183
+ readFile(join(REPO_ROOT, '.github', 'actions', action.name, 'action.yml'), 'utf8'),
184
+ ]);
185
+
186
+ expect(generated).toBe(template.replace('{{NODE_MODULES_CACHE_KEY}}', NODE_MODULES_CACHE_KEY));
187
+ }
188
+ });
189
+
190
+ it('scopes every primary, restore, and save key to the runner OS and architecture', async () => {
191
+ for (const action of CACHE_ACTIONS) {
192
+ for (const actionRoot of [
193
+ join(REPO_ROOT, 'packages', 'cli', 'managed', 'templates', 'github', 'actions'),
194
+ join(REPO_ROOT, '.github', 'actions'),
195
+ ]) {
196
+ const content = await readFile(join(actionRoot, action.name, 'action.yml'), 'utf8');
197
+ const osKeyLines = content.split('\n').filter((line) => line.includes('${{ runner.os }}'));
198
+
199
+ expect(osKeyLines).toHaveLength(action.osKeyLines);
200
+ expect(osKeyLines.every((line) => line.includes(ARCHITECTURE_SCOPED_PREFIX))).toBe(true);
201
+ }
202
+ }
203
+ });
204
+ });
@@ -1,7 +1,9 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
+ import { type PackageJson, parseNxProjectJsonText, parseStringArrayText } from '../lib/json.js';
5
7
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
6
8
  import { renderCiWorkflowYaml } from './ci-workflow.js';
7
9
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
@@ -137,6 +139,7 @@ interface ManagedFileContext {
137
139
  ciPushBranches: string[];
138
140
  nodeModulesCacheKey: string;
139
141
  repoName: string;
142
+ platformTargetGlobs: string[];
140
143
  }
141
144
 
142
145
  interface DeployTargetInfo {
@@ -207,6 +210,11 @@ const managedFiles: ManagedFile[] = [
207
210
  target: '.github/workflows/publish.yml',
208
211
  releasePackagesOnly: true,
209
212
  },
213
+ {
214
+ kind: 'template',
215
+ source: 'github/workflows/managed-files.yml',
216
+ target: '.github/workflows/managed-files.yml',
217
+ },
210
218
  {
211
219
  kind: 'template',
212
220
  source: 'github/actions/cache-nix-devenv/action.yml',
@@ -227,6 +235,11 @@ const managedFiles: ManagedFile[] = [
227
235
  source: 'github/actions/cache-node-modules/action.yml',
228
236
  target: '.github/actions/cache-node-modules/action.yml',
229
237
  },
238
+ {
239
+ kind: 'template',
240
+ source: 'github/actions/cache-ttsc-plugins/action.yml',
241
+ target: '.github/actions/cache-ttsc-plugins/action.yml',
242
+ },
230
243
  {
231
244
  kind: 'template',
232
245
  source: 'github/actions/cache-nx/action.yml',
@@ -295,6 +308,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
295
308
  deploy: context.hasProductionDeployTargets,
296
309
  deployProvider: context.productionDeployProvider,
297
310
  repoName: context.repoName,
311
+ platformTargetGlobs: context.platformTargetGlobs,
298
312
  });
299
313
  }
300
314
  throw new Error(`Unknown generated managed file source ${file.source}`);
@@ -314,6 +328,7 @@ function getManagedFileContext(root: string): ManagedFileContext {
314
328
  const ciPushBranches = getCiPushBranches(packageJson?.json);
315
329
  const stagingDeploy = getDeployTargetInfo(root, 'staging');
316
330
  const productionDeploy = getDeployTargetInfo(root, 'production');
331
+ const platformTargetGlobs = platformTargetGlobsForTest(readResolvedNxTargetNames(root));
317
332
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
318
333
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
319
334
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
@@ -326,9 +341,46 @@ function getManagedFileContext(root: string): ManagedFileContext {
326
341
  ciPushBranches,
327
342
  nodeModulesCacheKey,
328
343
  repoName,
344
+ platformTargetGlobs,
329
345
  };
330
346
  }
331
347
 
348
+ export function platformTargetGlobsForTest(targetNames: Iterable<string>): string[] {
349
+ const names = [...targetNames];
350
+ return PLATFORM_TARGET_GLOBS.filter((glob) => {
351
+ const suffix = glob.startsWith('*') ? glob.slice(1) : glob;
352
+ return names.some((name) => name.endsWith(suffix));
353
+ });
354
+ }
355
+
356
+ function readResolvedNxTargetNames(root: string): string[] {
357
+ const output = execFileSync('nx', ['show', 'projects', '--json'], {
358
+ cwd: root,
359
+ encoding: 'utf8',
360
+ stdio: ['ignore', 'pipe', 'inherit'],
361
+ });
362
+ const projects = parseStringArrayText(output);
363
+ if (!projects) {
364
+ return [];
365
+ }
366
+ const targetNames = new Set<string>();
367
+ for (const project of projects) {
368
+ const projectOutput = execFileSync('nx', ['show', 'project', project, '--json'], {
369
+ cwd: root,
370
+ encoding: 'utf8',
371
+ stdio: ['ignore', 'pipe', 'inherit'],
372
+ });
373
+ const projectJson = parseNxProjectJsonText(projectOutput);
374
+ const targets = projectJson?.targets;
375
+ if (targets) {
376
+ for (const targetName of Object.keys(targets)) {
377
+ targetNames.add(targetName);
378
+ }
379
+ }
380
+ }
381
+ return [...targetNames];
382
+ }
383
+
332
384
  function renderTemplate(context: ManagedFileContext, template: string): string {
333
385
  return template
334
386
  .replaceAll('{{REPO_NAME}}', context.repoName)
@@ -342,8 +394,8 @@ function getDeployTargetInfo(root: string, configuration: string): DeployTargetI
342
394
  encoding: 'utf8',
343
395
  stdio: ['ignore', 'pipe', 'inherit'],
344
396
  });
345
- const projects: unknown = JSON.parse(output);
346
- if (!Array.isArray(projects) || !projects.every((project) => typeof project === 'string')) {
397
+ const projects = parseStringArrayText(output);
398
+ if (!projects) {
347
399
  return { exists: false };
348
400
  }
349
401
  let exists = false;
@@ -365,47 +417,25 @@ function nxDeployTarget(root: string, project: string, configuration: string): D
365
417
  encoding: 'utf8',
366
418
  stdio: ['ignore', 'pipe', 'inherit'],
367
419
  });
368
- const parsed: unknown = JSON.parse(output);
369
- const projectJson = recordValue(parsed);
370
- const targets = recordValue(projectJson?.targets);
371
- const deploy = recordValue(targets?.deploy);
372
- const configurations = recordValue(deploy?.configurations);
373
- const config = recordValue(configurations?.[configuration]);
420
+ const projectJson = parseNxProjectJsonText(output);
421
+ const deploy = projectJson?.targets?.deploy;
422
+ const config = deploy?.configurations?.[configuration];
374
423
  if (!config) {
375
424
  return { exists: false };
376
425
  }
377
- const command = deployCommand(deploy, config);
426
+ const commandValue = config.command ?? config.options?.command ?? deploy?.options?.command;
427
+ const command = typeof commandValue === 'string' ? commandValue : '';
378
428
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
379
429
  }
380
430
 
381
- function deployCommand(deploy: Record<string, unknown> | undefined, config: Record<string, unknown>): string {
382
- const command = stringValue(config.command) ?? stringValue(recordValue(config.options)?.command);
383
- return command ?? stringValue(recordValue(deploy?.options)?.command) ?? '';
384
- }
385
-
386
- function stringValue(value: unknown): string | undefined {
387
- return typeof value === 'string' ? value : undefined;
388
- }
389
-
390
- function getCiPushBranches(packageJson: unknown): string[] {
431
+ function getCiPushBranches(packageJson: PackageJson | null | undefined): string[] {
391
432
  const configured = readCiPushBranches(packageJson);
392
433
  return configured.length > 0 ? configured : ['main'];
393
434
  }
394
435
 
395
- function readCiPushBranches(packageJson: unknown): string[] {
396
- const rootPackage = recordValue(packageJson);
397
- const smoo = recordValue(rootPackage?.smoo);
398
- const github = recordValue(smoo?.github);
399
- const branches = Array.isArray(github?.pushBranches) ? github.pushBranches : [];
400
- return branches.filter((branch): branch is string => typeof branch === 'string' && branch.length > 0);
401
- }
402
-
403
- function recordValue(value: unknown): Record<string, unknown> | undefined {
404
- return isRecord(value) ? value : undefined;
405
- }
406
-
407
- function isRecord(value: unknown): value is Record<string, unknown> {
408
- return value !== null && typeof value === 'object' && !Array.isArray(value);
436
+ function readCiPushBranches(packageJson: PackageJson | null | undefined): string[] {
437
+ const branches = packageJson?.smoo?.github?.pushBranches ?? [];
438
+ return branches.filter((branch) => branch.length > 0);
409
439
  }
410
440
 
411
441
  function renderYamlFlowList(values: string[]): string {
@@ -432,3 +462,33 @@ export function validateManagedFiles(root: string): number {
432
462
  }
433
463
  return failures;
434
464
  }
465
+
466
+ /**
467
+ * Non-blocking drift report: prints the per-file table and surfaces drift as
468
+ * GitHub Actions warning annotations (plain stderr elsewhere) without failing
469
+ * the run. Managed-file drift is derived state with its own remediation flow
470
+ * (the persistent managed-files PR); only `monorepo check` without --warn and
471
+ * PR-scoped gates treat it as an error.
472
+ *
473
+ * Under GitHub Actions the drifted-file count is published as the step output
474
+ * `drifted`, so downstream steps gate declaratively instead of parsing logs.
475
+ */
476
+ export function warnOnManagedFileDrift(root: string): void {
477
+ const results = applyManagedFiles(root, 'check');
478
+ printResults(results);
479
+ const drifted = results.filter((result) => result.action === 'drifted');
480
+ if (process.env.GITHUB_OUTPUT) {
481
+ appendFileSync(process.env.GITHUB_OUTPUT, `drifted=${drifted.length}\n`);
482
+ }
483
+ if (drifted.length === 0) {
484
+ return;
485
+ }
486
+ if (process.env.GITHUB_ACTIONS === 'true') {
487
+ for (const result of drifted) {
488
+ console.log(
489
+ `::warning title=Managed file drift::${result.target} drifted from the @smoothbricks/cli template; run 'smoo monorepo update'`,
490
+ );
491
+ }
492
+ }
493
+ console.error(`${drifted.length} managed monorepo file(s) drifted (non-blocking). Run: smoo monorepo update`);
494
+ }