@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
@@ -1,7 +1,16 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import { dirname, join, relative } from 'node:path';
3
3
  import { isSmoothBricksCodebasePackageName } from './cli-package.js';
4
- import { hasOwn, hasOwnString, isRecord, readJson, readJsonObject, stringProperty } from './json.js';
4
+ import {
5
+ isPackageJson,
6
+ type PackageJson,
7
+ type PackageNxConfig,
8
+ type PackageRepository,
9
+ readJson,
10
+ readJsonObject,
11
+ } from './json.js';
12
+
13
+ export type { PackageJson, PackageNxConfig, PackageRepository };
5
14
 
6
15
  export interface PackageInfo {
7
16
  name: string;
@@ -11,7 +20,7 @@ export interface PackageInfo {
11
20
  tags: string[];
12
21
  path: string;
13
22
  packageJsonPath: string;
14
- json: Record<string, unknown>;
23
+ json: PackageJson;
15
24
  }
16
25
 
17
26
  export interface WorkspacePackageManifest {
@@ -20,18 +29,7 @@ export interface WorkspacePackageManifest {
20
29
  private: boolean;
21
30
  path: string;
22
31
  packageJsonPath: string;
23
- json: Record<string, unknown>;
24
- }
25
-
26
- export interface PackageJson extends Record<string, unknown> {
27
- name?: string;
28
- version?: string;
29
- private?: boolean;
30
- workspaces?: unknown;
31
- devDependencies?: unknown;
32
- dependencies?: unknown;
33
- nx?: unknown;
34
- repository?: unknown;
32
+ json: PackageJson;
35
33
  }
36
34
 
37
35
  export interface RepositoryInfo {
@@ -46,6 +44,8 @@ export const workspaceDependencyFields = [
46
44
  'optionalDependencies',
47
45
  ] as const;
48
46
 
47
+ export type WorkspaceDependencyField = (typeof workspaceDependencyFields)[number];
48
+
49
49
  export function listPublicPackages(root: string): PackageInfo[] {
50
50
  return getWorkspacePackages(root).filter((pkg) => !pkg.private && pkg.tags.includes('npm:public'));
51
51
  }
@@ -54,26 +54,21 @@ export function listReleasePackages(
54
54
  root: string,
55
55
  rootPackage = readPackageJson(join(root, 'package.json')),
56
56
  ): PackageInfo[] {
57
- if (!rootPackage) {
58
- return [];
59
- }
60
- const rootRepository = repositoryInfo(rootPackage.json);
57
+ const rootRepository = rootPackage ? repositoryInfo(rootPackage.json) : null;
61
58
  if (!rootRepository) {
62
59
  return [];
63
60
  }
64
- return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json)).filter(
65
- (pkg) => !pkg.private && pkg.tags.includes('npm:public') && isOwnedPackage(rootRepository, pkg),
66
- );
61
+ return listPublicPackages(root).filter((pkg) => isOwnedPackage(rootRepository, pkg));
67
62
  }
68
63
 
69
64
  export function rootRepositoryInfo(root: string): RepositoryInfo | null {
70
- const rootPackage = readJsonObject(join(root, 'package.json'));
71
- return rootPackage ? repositoryInfo(rootPackage) : null;
65
+ const rootPackage = readPackageJson(join(root, 'package.json'));
66
+ return rootPackage ? repositoryInfo(rootPackage.json) : null;
72
67
  }
73
68
 
74
69
  export function rootPackageName(root: string): string | null {
75
70
  const rootPackage = readPackageJsonObject(join(root, 'package.json'));
76
- return rootPackage ? stringProperty(rootPackage, 'name') : null;
71
+ return rootPackage?.name ?? null;
77
72
  }
78
73
 
79
74
  export function isSmoothBricksCodebase(root: string): boolean {
@@ -90,37 +85,23 @@ export function isOwnedPackage(rootRepository: RepositoryInfo, pkg: PackageInfo)
90
85
  }
91
86
 
92
87
  export function getWorkspacePackages(root: string): PackageInfo[] {
93
- const rootPackage = readPackageJson(join(root, 'package.json'));
94
- if (!rootPackage) {
95
- throw new Error('package.json not found or invalid');
96
- }
97
- return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json));
88
+ return getWorkspacePackagesForPatterns(root, getWorkspacePatterns(root));
98
89
  }
99
90
 
100
91
  export function getWorkspacePackageManifests(root: string): WorkspacePackageManifest[] {
101
- const rootPackage = readPackageJsonObject(join(root, 'package.json'));
102
- if (!rootPackage) {
103
- throw new Error('package.json not found or invalid');
104
- }
105
- return getWorkspacePackageManifestsForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage));
92
+ return getWorkspacePackageManifestsForPatterns(root, getWorkspacePatterns(root));
106
93
  }
107
94
 
108
95
  function getWorkspacePackagesForPatterns(root: string, workspacePatterns: string[]): PackageInfo[] {
109
96
  const packages: PackageInfo[] = [];
110
- for (const pkgPath of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
111
- const pkg = readPackageJson(pkgPath);
112
- if (!pkg?.name || !pkg.version) {
97
+ for (const path of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
98
+ const pkg = readPackageJson(path);
99
+ if (!pkg) {
113
100
  continue;
114
101
  }
115
102
  packages.push({
116
- name: pkg.name,
117
- projectName: pkg.projectName,
118
- version: pkg.version,
119
- private: pkg.private,
120
- tags: pkg.tags,
121
- path: relative(root, dirname(pkgPath)),
122
- packageJsonPath: pkg.packageJsonPath,
123
- json: pkg.json,
103
+ ...pkg,
104
+ path: relative(root, dirname(path)),
124
105
  });
125
106
  }
126
107
  return packages.sort((a, b) => a.name.localeCompare(b.name));
@@ -130,30 +111,42 @@ function getWorkspacePackageManifestsForPatterns(
130
111
  root: string,
131
112
  workspacePatterns: string[],
132
113
  ): WorkspacePackageManifest[] {
133
- const manifests: WorkspacePackageManifest[] = [];
134
- for (const pkgPath of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
135
- const pkg = readWorkspacePackageManifest(pkgPath);
136
- if (pkg) {
137
- manifests.push(pkg);
114
+ const packages: WorkspacePackageManifest[] = [];
115
+ for (const path of listWorkspacePackageJsonPaths(root, workspacePatterns)) {
116
+ const pkg = readWorkspacePackageManifest(path);
117
+ if (!pkg) {
118
+ continue;
138
119
  }
120
+ packages.push({
121
+ ...pkg,
122
+ path: relative(root, dirname(path)),
123
+ });
139
124
  }
140
- return manifests.sort((a, b) => a.name.localeCompare(b.name));
125
+ return packages.sort((a, b) => a.name.localeCompare(b.name));
141
126
  }
142
127
 
143
128
  function listWorkspacePackageJsonPaths(root: string, workspacePatterns: string[]): string[] {
144
129
  const paths: string[] = [];
145
130
  for (const pattern of workspacePatterns) {
146
131
  if (!pattern.endsWith('/*')) {
147
- paths.push(join(root, pattern, 'package.json'));
148
132
  continue;
149
133
  }
150
-
151
134
  const parent = join(root, pattern.slice(0, -2));
152
- if (!statSync(parent, { throwIfNoEntry: false })?.isDirectory()) {
135
+ let entries: string[];
136
+ try {
137
+ entries = readdirSync(parent);
138
+ } catch {
153
139
  continue;
154
140
  }
155
- for (const entry of readdirSync(parent)) {
156
- paths.push(join(parent, entry, 'package.json'));
141
+ for (const entry of entries) {
142
+ const packageJsonPath = join(parent, entry, 'package.json');
143
+ try {
144
+ if (statSync(packageJsonPath).isFile()) {
145
+ paths.push(packageJsonPath);
146
+ }
147
+ } catch {
148
+ // skip missing package.json
149
+ }
157
150
  }
158
151
  }
159
152
  return paths;
@@ -162,7 +155,7 @@ function listWorkspacePackageJsonPaths(root: string, workspacePatterns: string[]
162
155
  export function listPackageJsonRecords(root: string): PackageInfo[] {
163
156
  const rootPackage = readPackageJson(join(root, 'package.json'));
164
157
  if (!rootPackage) {
165
- throw new Error('package.json not found or invalid');
158
+ return getWorkspacePackages(root);
166
159
  }
167
160
  return [
168
161
  { ...rootPackage, path: '.' },
@@ -172,18 +165,16 @@ export function listPackageJsonRecords(root: string): PackageInfo[] {
172
165
 
173
166
  export function getWorkspacePatterns(root: string): string[] {
174
167
  const raw = readJson(join(root, 'package.json'));
175
- return isRecord(raw) ? getWorkspacePatternsFromPackageJson(raw) : ['packages/*'];
168
+ const pkg = isPackageJson(raw) ? raw : null;
169
+ return pkg ? getWorkspacePatternsFromPackageJson(pkg) : ['packages/*'];
176
170
  }
177
171
 
178
- function getWorkspacePatternsFromPackageJson(pkg: Record<string, unknown>): string[] {
179
- if (!hasOwn(pkg, 'workspaces')) {
180
- return ['packages/*'];
181
- }
172
+ function getWorkspacePatternsFromPackageJson(pkg: PackageJson): string[] {
182
173
  const workspaces = pkg.workspaces;
183
174
  if (Array.isArray(workspaces)) {
184
175
  return workspaces.filter((entry): entry is string => typeof entry === 'string');
185
176
  }
186
- if (isRecord(workspaces) && hasOwn(workspaces, 'packages') && Array.isArray(workspaces.packages)) {
177
+ if (workspaces && typeof workspaces === 'object' && Array.isArray(workspaces.packages)) {
187
178
  return workspaces.packages.filter((entry): entry is string => typeof entry === 'string');
188
179
  }
189
180
  return ['packages/*'];
@@ -191,17 +182,15 @@ function getWorkspacePatternsFromPackageJson(pkg: Record<string, unknown>): stri
191
182
 
192
183
  export function readPackageJson(path: string): PackageInfo | null {
193
184
  const parsed = readPackageJsonObject(path);
194
- if (!isRecord(parsed) || !hasOwnString(parsed, 'name') || !hasOwnString(parsed, 'version')) {
185
+ if (!parsed?.name || !parsed.version) {
195
186
  return null;
196
187
  }
197
- const privateValue = hasOwn(parsed, 'private') && typeof parsed.private === 'boolean' ? parsed.private : false;
198
- const tags = getNxTags(parsed);
199
188
  return {
200
189
  name: parsed.name,
201
190
  projectName: packageNxProjectName(parsed),
202
191
  version: parsed.version,
203
- private: privateValue,
204
- tags,
192
+ private: parsed.private === true,
193
+ tags: getNxTags(parsed),
205
194
  path: dirname(path),
206
195
  packageJsonPath: path,
207
196
  json: parsed,
@@ -210,50 +199,48 @@ export function readPackageJson(path: string): PackageInfo | null {
210
199
 
211
200
  export function readWorkspacePackageManifest(path: string): WorkspacePackageManifest | null {
212
201
  const parsed = readPackageJsonObject(path);
213
- if (!isRecord(parsed) || !hasOwnString(parsed, 'name')) {
202
+ if (!parsed?.name) {
214
203
  return null;
215
204
  }
216
- const privateValue = hasOwn(parsed, 'private') && typeof parsed.private === 'boolean' ? parsed.private : false;
217
205
  return {
218
206
  name: parsed.name,
219
207
  projectName: packageNxProjectName(parsed),
220
- private: privateValue,
208
+ private: parsed.private === true,
221
209
  path: dirname(path),
222
210
  packageJsonPath: path,
223
211
  json: parsed,
224
212
  };
225
213
  }
226
214
 
227
- function packageNxProjectName(pkg: Record<string, unknown>): string {
228
- const nx = isRecord(pkg.nx) ? pkg.nx : null;
229
- return (nx ? stringProperty(nx, 'name') : null) ?? stringProperty(pkg, 'name') ?? '';
215
+ function packageNxProjectName(pkg: PackageJson): string {
216
+ return pkg.nx?.name ?? pkg.name ?? '';
230
217
  }
231
218
 
232
219
  export function readPackageJsonObject(path: string): PackageJson | null {
233
- const parsed = readJsonObject(path);
234
- return parsed;
220
+ return readJsonObject(path);
235
221
  }
236
222
 
237
- export function getNxTags(pkg: Record<string, unknown>): string[] {
238
- if (!hasOwn(pkg, 'nx') || !isRecord(pkg.nx) || !hasOwn(pkg.nx, 'tags') || !Array.isArray(pkg.nx.tags)) {
223
+ export function getNxTags(pkg: PackageJson): string[] {
224
+ const tags = pkg.nx?.tags;
225
+ if (!Array.isArray(tags)) {
239
226
  return [];
240
227
  }
241
- return pkg.nx.tags.filter((tag): tag is string => typeof tag === 'string');
228
+ return tags.filter((tag): tag is string => typeof tag === 'string');
242
229
  }
243
230
 
244
- export function repositoryInfo(pkg: Record<string, unknown>): RepositoryInfo | null {
231
+ export function repositoryInfo(pkg: PackageJson): RepositoryInfo | null {
245
232
  const repository = pkg.repository;
246
233
  if (typeof repository === 'string') {
247
234
  return { type: 'git', url: repository };
248
235
  }
249
- if (!isRecord(repository)) {
236
+ if (!repository || typeof repository !== 'object') {
250
237
  return null;
251
238
  }
252
- const url = stringProperty(repository, 'url');
239
+ const url = repository.url;
253
240
  if (!url) {
254
241
  return null;
255
242
  }
256
- return { type: stringProperty(repository, 'type') ?? 'git', url };
243
+ return { type: repository.type ?? 'git', url };
257
244
  }
258
245
 
259
246
  export function sameRepositoryAfterNormalization(left: string, right: string): boolean {
@@ -261,25 +248,26 @@ export function sameRepositoryAfterNormalization(left: string, right: string): b
261
248
  }
262
249
 
263
250
  function normalizedRepositoryUrl(url: string): string {
264
- const trimmed = url
265
- .trim()
266
- .replace(/^git\+/, '')
267
- .replace(/#.*$/, '')
268
- .replace(/\/$/, '')
269
- .replace(/\.git$/, '');
270
- const https = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/i.exec(trimmed);
271
- if (https?.[1] && https[2]) {
272
- return githubRepositoryKey(https[1], https[2]);
273
- }
274
- const ssh = /^git@github\.com:([^/]+)\/([^/]+)$/i.exec(trimmed);
275
- if (ssh?.[1] && ssh[2]) {
276
- return githubRepositoryKey(ssh[1], ssh[2]);
251
+ const trimmed = url.trim().replace(/\.git$/i, '');
252
+ const ssh = /^git@github\.com:(.+)$/i.exec(trimmed);
253
+ if (ssh?.[1]) {
254
+ const [owner, repo] = ssh[1].split('/');
255
+ if (owner && repo) {
256
+ return githubRepositoryKey(owner, repo);
257
+ }
277
258
  }
278
- const shorthand = /^github:([^/]+)\/([^/]+)$/i.exec(trimmed);
279
- if (shorthand?.[1] && shorthand[2]) {
280
- return githubRepositoryKey(shorthand[1], shorthand[2]);
259
+ try {
260
+ const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
261
+ if (parsed.hostname.replace(/^www\./i, '').toLowerCase() === 'github.com') {
262
+ const [owner, repo] = parsed.pathname.replace(/^\//, '').split('/');
263
+ if (owner && repo) {
264
+ return githubRepositoryKey(owner, repo);
265
+ }
266
+ }
267
+ } catch {
268
+ // fall through
281
269
  }
282
- return trimmed;
270
+ return trimmed.toLowerCase();
283
271
  }
284
272
 
285
273
  function githubRepositoryKey(owner: string, repo: string): string {
@@ -17,14 +17,14 @@ describe('CI workflow definition', () => {
17
17
  const steps = defineCiWorkflow({ deploy: true, pushBranches: ['main'] });
18
18
  const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
19
19
 
20
- expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 9]);
20
+ expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 11]);
21
21
  expect(rendered).toContain('- name: ๐Ÿš€ Deploy Staging');
22
22
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
23
23
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
24
24
  expect(rendered).toContain(
25
- 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 9',
25
+ 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 11',
26
26
  );
27
- expect(rendered).toContain("# Step 10\n # Nx's database cache needs artifact files");
27
+ expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
28
28
  });
29
29
 
30
30
  it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
@@ -33,4 +33,15 @@ describe('CI workflow definition', () => {
33
33
  expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
34
34
  expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
35
35
  });
36
+
37
+ it('uses the same architecture-scoped key to restore and save the Nx cache', async () => {
38
+ const rendered = renderCiWorkflowYaml({ deploy: false, pushBranches: ['main'] });
39
+ const packageRoot = join(import.meta.dir, '..', '..', '..');
40
+ const restoreAction = await readFile(join(packageRoot, '..', '..', '.github/actions/cache-nx/action.yml'), 'utf8');
41
+ const restoreKey = restoreAction.match(/^\s*key: (.+)$/m)?.[1];
42
+ const saveKey = rendered.match(/^\s*key: (.+)$/m)?.[1];
43
+
44
+ expect(restoreKey).toBe('${{ runner.os }}-${{ runner.arch }}-nx-db-v1-${{ github.sha }}');
45
+ expect(saveKey).toBe(restoreKey);
46
+ });
36
47
  });
@@ -3,6 +3,12 @@
3
3
  import { describe, expect, it } from 'bun:test';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
+ import {
7
+ LINUX_PLATFORM_TARGET_GLOBS,
8
+ MACOS_PLATFORM_TARGET_GLOBS,
9
+ PLATFORM_TARGET_GLOBS,
10
+ } from '@smoothbricks/nx-plugin/workspace-config-policy';
11
+ import { format } from 'prettier';
6
12
  import {
7
13
  definePublishWorkflow,
8
14
  type PublishWorkflowBump,
@@ -15,13 +21,208 @@ import {
15
21
 
16
22
  describe('publish workflow definition', () => {
17
23
  it('renders the checked-in local publish workflow copy', async () => {
18
- const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
24
+ const rendered = renderPublishWorkflowYaml({
25
+ repoName: '@smoothbricks/codebase',
26
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
27
+ });
19
28
  const packageRoot = join(import.meta.dir, '..', '..', '..');
20
29
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
21
30
  rendered,
22
31
  );
23
32
  });
24
33
 
34
+ it('renders workflow bytes that are stable under the repository Prettier config', async () => {
35
+ const rendered = renderPublishWorkflowYaml({
36
+ repoName: '@smoothbricks/codebase',
37
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
38
+ });
39
+
40
+ await expect(
41
+ format(rendered, {
42
+ parser: 'yaml',
43
+ printWidth: 120,
44
+ proseWrap: 'always',
45
+ }),
46
+ ).resolves.toBe(rendered);
47
+ });
48
+
49
+ it('preserves the single Ubuntu job and renders no artifact transfer when no Apple targets exist', () => {
50
+ const current = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
51
+ const explicitlyEmpty = renderPublishWorkflowYaml({
52
+ repoName: '@smoothbricks/codebase',
53
+ platformTargetGlobs: [],
54
+ });
55
+ const linuxOnly = renderPublishWorkflowYaml({
56
+ repoName: '@smoothbricks/codebase',
57
+ platformTargetGlobs: LINUX_PLATFORM_TARGET_GLOBS,
58
+ });
59
+
60
+ expect(explicitlyEmpty).toBe(current);
61
+ for (const rendered of [current, linuxOnly]) {
62
+ const jobsYaml = rendered.slice(rendered.indexOf('jobs:\n') + 'jobs:\n'.length);
63
+ expect(jobsYaml).toContain(' publish:\n runs-on: ubuntu-latest');
64
+ expect(jobsYaml.match(/^ {2}[a-z][a-z-]+:\n/gm)).toHaveLength(1);
65
+ expect(rendered).not.toContain('linux-release-candidate:');
66
+ expect(rendered).not.toContain('macos-platform:');
67
+ expect(rendered).not.toContain('publish-on-linux:');
68
+ expect(rendered).not.toContain('--collect-outputs');
69
+ expect(rendered).not.toContain('Upload validated release state');
70
+ expect(rendered).not.toContain('Upload validated build outputs');
71
+ expect(rendered).not.toContain('Upload supplemental Linux outputs');
72
+ expect(rendered).not.toContain('Upload macOS platform outputs');
73
+ expect(rendered).not.toContain('actions/download-artifact');
74
+ expect(rendered).not.toContain('apply-outputs');
75
+ expect(rendered).not.toContain('git bundle');
76
+ }
77
+ });
78
+
79
+ it('keeps job-local deeplink step anchors exact for single-job and parallel platform workflows', () => {
80
+ const singleJob = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
81
+ const platform = renderPublishWorkflowYaml({
82
+ deploy: true,
83
+ repoName: '@smoothbricks/codebase',
84
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
85
+ });
86
+ const linuxCandidate = platform.slice(
87
+ platform.indexOf(' linux-release-candidate:'),
88
+ platform.indexOf(' macos-platform:'),
89
+ );
90
+ const macosPlatform = platform.slice(
91
+ platform.indexOf(' macos-platform:'),
92
+ platform.indexOf(' publish-on-linux:'),
93
+ );
94
+ const finalJob = platform.slice(platform.indexOf(' publish-on-linux:'));
95
+
96
+ expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 15 }, (_, index) => index + 1));
97
+ expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 19 }, (_, index) => index + 1));
98
+ expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 7 }, (_, index) => index + 1));
99
+ expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 14 }, (_, index) => index + 1));
100
+ expect(singleJob).toContain('# Step 14\n - name: ๐Ÿ“ฆ Publish release (${{ steps.version.outputs.mode }})');
101
+ expect(singleJob).toContain('# Step 15\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
102
+ expect(linuxCandidate).toContain('# Step 7\n - name: ๐Ÿ”’ Capture candidate release SHA');
103
+ expect(linuxCandidate).toContain(
104
+ '# Step 8\n - name: โœ… Check managed monorepo files (${{ steps.version.outputs.mode }})',
105
+ );
106
+ expect(linuxCandidate).toContain('# Step 19\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
107
+ expect(macosPlatform).toContain('# Step 5\n - name: ๐ŸŽ Build selected macOS and iOS release outputs');
108
+ expect(macosPlatform).toContain('# Step 7\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
109
+ expect(finalJob).toContain('# Step 7\n - name: ๐Ÿงฏ Repair pending releases');
110
+ expect(finalJob).toContain('# Step 8\n - name: โ™ป๏ธ Restore validated release state');
111
+ expect(finalJob).toContain('# Step 9\n - name: ๐Ÿ“ฆ Apply verified Linux outputs');
112
+ expect(finalJob).toContain('# Step 10\n - name: ๐ŸŽ Apply verified macOS outputs');
113
+ expect(finalJob).toContain(
114
+ '# Step 12\n - name: ๐Ÿ“ฆ Publish release (${{ needs.linux-release-candidate.outputs.mode }})',
115
+ );
116
+ expect(finalJob).toContain('# Step 13\n - name: ๐Ÿš€ Deploy production');
117
+ expect(finalJob).toContain('# Step 14\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
118
+ });
119
+
120
+ it('renders parallel native producers and a dependent publish-only final job', () => {
121
+ const linuxOnly = renderPublishWorkflowYaml({
122
+ repoName: '@smoothbricks/codebase',
123
+ platformTargetGlobs: LINUX_PLATFORM_TARGET_GLOBS,
124
+ });
125
+ const native = renderPublishWorkflowYaml({
126
+ repoName: '@smoothbricks/codebase',
127
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
128
+ });
129
+ const linuxCandidate = native.slice(
130
+ native.indexOf(' linux-release-candidate:'),
131
+ native.indexOf(' macos-platform:'),
132
+ );
133
+ const macosPlatform = native.slice(native.indexOf(' macos-platform:'), native.indexOf(' publish-on-linux:'));
134
+ const finalJob = native.slice(native.indexOf(' publish-on-linux:'));
135
+
136
+ expect(linuxOnly).not.toContain('macos-platform:');
137
+ expect(linuxOnly).toContain(
138
+ `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
139
+ );
140
+ expect(native).toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
141
+ expect(native).toContain(' macos-platform:\n runs-on: macos-latest');
142
+ expect(native).toContain(' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]');
143
+ expect(linuxCandidate).not.toContain('needs:');
144
+ expect(macosPlatform).not.toContain('needs:');
145
+ expect(linuxCandidate).not.toContain('smoo release repair-pending');
146
+ expect(linuxCandidate).toContain('smoo release version');
147
+ expect(linuxCandidate).toContain('smoo github-ci nx-run-many --targets build --projects');
148
+ expect(linuxCandidate).toContain('smoo github-ci nx-run-many --targets lint --projects');
149
+ expect(linuxCandidate).toContain('smoo github-ci nx-run-many --targets test --projects');
150
+ expect(linuxCandidate).toContain(
151
+ `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
152
+ );
153
+ expect(macosPlatform).not.toContain('smoo github-ci nx-run-many');
154
+ expect(foldedRunCommand(macosPlatform, '๐ŸŽ Build selected macOS and iOS release outputs')).toBe(
155
+ `smoo release build-platform-outputs --bump "\${{ inputs.bump }}" --ref "\${{ github.sha }}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(
156
+ ',',
157
+ )}" --output "\${{ runner.temp }}/macos-platform-outputs"`,
158
+ );
159
+ expect(finalJob).not.toContain('smoo release version');
160
+ expect(finalJob).not.toContain('nx-run-many');
161
+ expect(foldedRunCommand(finalJob, '๐Ÿงฏ Repair pending releases')).toBe(
162
+ 'smoo release repair-pending --ref "${{ github.sha }}" --platform-outputs ' +
163
+ '"${{ runner.temp }}/publish-artifacts/publish-macos-outputs-${{ github.run_id }}/repairs" ' +
164
+ '--dry-run "${{ inputs.dry_run }}"',
165
+ );
166
+ expect(native).toContain('uses: actions/upload-artifact@v7.0.1');
167
+ expect(native).toContain('uses: actions/download-artifact@v8.0.1');
168
+ expect(native).toContain('name: publish-release-state-${{ github.run_id }}');
169
+ expect(native).toContain('name: publish-release-outputs-${{ github.run_id }}');
170
+ expect(native).toContain('name: publish-linux-outputs-${{ github.run_id }}');
171
+ expect(native.match(/include-hidden-files: true/g)).toHaveLength(4);
172
+ expect(native).toContain('name: publish-macos-outputs-${{ github.run_id }}');
173
+ expect(native).toContain('git bundle create');
174
+ expect(native).toContain('git fetch "${{ runner.temp }}/publish-artifacts/publish-release-state-');
175
+ expect(finalJob).toContain(
176
+ 'smoo github-ci apply-outputs --source-sha "${{ needs.linux-release-candidate.outputs.release-sha }}"',
177
+ );
178
+ expect(finalJob).toContain('smoo github-ci apply-outputs --source-sha "${{ github.sha }}"');
179
+ expect(foldedRunCommand(finalJob, '๐Ÿ“ฆ Apply verified Linux outputs')).toBe(
180
+ 'smoo github-ci apply-outputs --source-sha "${{ needs.linux-release-candidate.outputs.release-sha }}" ' +
181
+ '"${{ runner.temp }}/publish-artifacts/publish-release-outputs-${{ github.run_id }}" ' +
182
+ '"${{ runner.temp }}/publish-artifacts/publish-linux-outputs-${{ github.run_id }}"',
183
+ );
184
+ expect(foldedRunCommand(finalJob, '๐ŸŽ Apply verified macOS outputs')).toBe(
185
+ 'smoo github-ci apply-outputs --source-sha "${{ github.sha }}" ' +
186
+ '"${{ runner.temp }}/publish-artifacts/publish-macos-outputs-${{ github.run_id }}/current"',
187
+ );
188
+ });
189
+
190
+ it('limits trusted publishing permission to final publish and preserves mode, deploy, and dry-run gates', () => {
191
+ const rendered = renderPublishWorkflowYaml({
192
+ deploy: true,
193
+ deployProvider: 'cloudflare',
194
+ repoName: '@smoothbricks/codebase',
195
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
196
+ });
197
+ const linuxCandidate = rendered.slice(
198
+ rendered.indexOf(' linux-release-candidate:'),
199
+ rendered.indexOf(' macos-platform:'),
200
+ );
201
+ const macosPlatform = rendered.slice(
202
+ rendered.indexOf(' macos-platform:'),
203
+ rendered.indexOf(' publish-on-linux:'),
204
+ );
205
+ const finalJob = rendered.slice(rendered.indexOf(' publish-on-linux:'));
206
+
207
+ expect(linuxCandidate).toContain('id-token: none');
208
+ expect(macosPlatform).toContain('id-token: none');
209
+ expect(finalJob).toContain('id-token: write');
210
+ expect(rendered.match(/id-token: write/g)).toHaveLength(1);
211
+ expect(finalJob.indexOf('๐Ÿงฏ Repair pending releases')).toBeLessThan(
212
+ finalJob.indexOf('โ™ป๏ธ Restore validated release state'),
213
+ );
214
+ expect(finalJob.indexOf('โ™ป๏ธ Restore validated release state')).toBeGreaterThan(
215
+ finalJob.indexOf('๐Ÿงฑ Setup Nix/devenv'),
216
+ );
217
+ expect(rendered).not.toContain('GITHUB_SHA:');
218
+ expect(finalJob).toContain("needs.linux-release-candidate.outputs.mode != 'none'");
219
+ expect(finalJob).toContain("inputs.deploy_environment == 'production'");
220
+ expect(finalJob).toContain("inputs.dry_run != 'true'");
221
+ expect(finalJob).toContain('smoo release publish --bump "${{ inputs.bump }}" --dry-run "${{ inputs.dry_run }}"');
222
+ expect(finalJob).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
223
+ expect(finalJob).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
224
+ });
225
+
25
226
  it('does not wire npm token secrets into repair or publish steps', () => {
26
227
  const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
27
228
 
@@ -384,6 +585,26 @@ class WorkflowScenarioState {
384
585
  }
385
586
  }
386
587
 
588
+ function stepAnchorNumbers(yaml: string): number[] {
589
+ return [...yaml.matchAll(/^\s*# Step (\d+)/gm)].map((match) => Number(match[1]));
590
+ }
591
+
592
+ function foldedRunCommand(yaml: string, stepName: string): string {
593
+ const stepStart = yaml.indexOf(`- name: ${stepName}`);
594
+ expect(stepStart).toBeGreaterThanOrEqual(0);
595
+ const runMarker = ' run:\n';
596
+ const runStart = yaml.indexOf(runMarker, stepStart);
597
+ expect(runStart).toBeGreaterThan(stepStart);
598
+ const commandLines: string[] = [];
599
+ for (const line of yaml.slice(runStart + runMarker.length).split('\n')) {
600
+ if (!line.startsWith(' ')) {
601
+ break;
602
+ }
603
+ commandLines.push(line.trim());
604
+ }
605
+ return commandLines.join(' ');
606
+ }
607
+
387
608
  function packageNameFromTag(tag: string): string {
388
609
  const versionSeparator = tag.lastIndexOf('@');
389
610
  if (versionSeparator <= 0) {