@smoothbricks/cli 0.3.3 โ 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +14 -0
- package/dist/github-ci/index.d.ts +12 -0
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +72 -4
- package/dist/monorepo/ci-workflow.d.ts +26 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -0
- package/dist/monorepo/ci-workflow.js +201 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +97 -5
- package/dist/monorepo/package-policy.d.ts.map +1 -1
- package/dist/monorepo/package-policy.js +4 -2
- package/dist/monorepo/publish-workflow.d.ts +7 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +59 -15
- package/dist/monorepo/runtime.d.ts +3 -0
- package/dist/monorepo/runtime.d.ts.map +1 -1
- package/dist/monorepo/runtime.js +66 -2
- package/managed/raw/git-format-staged.yml +4 -0
- package/package.json +3 -3
- package/src/cli.ts +35 -5
- package/src/github-ci/index.ts +94 -8
- package/src/monorepo/__tests__/ci-workflow.test.ts +34 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +65 -2
- package/src/monorepo/ci-workflow.ts +229 -0
- package/src/monorepo/managed-files.ts +115 -5
- package/src/monorepo/package-policy.test.ts +24 -9
- package/src/monorepo/package-policy.ts +4 -2
- package/src/monorepo/publish-workflow.ts +73 -16
- package/src/monorepo/runtime.test.ts +30 -0
- package/src/monorepo/runtime.ts +94 -2
- package/src/release/__tests__/fixture-repo.test.ts +14 -13
- package/src/release/__tests__/helpers/fixture-repo.ts +85 -12
- package/managed/templates/github/workflows/ci.yml +0 -102
|
@@ -14,6 +14,7 @@ export var PublishWorkflowStepKind;
|
|
|
14
14
|
PublishWorkflowStepKind["UploadTraceDbs"] = "upload-trace-dbs";
|
|
15
15
|
PublishWorkflowStepKind["ValidateMonorepoConfig"] = "validate-monorepo-config";
|
|
16
16
|
PublishWorkflowStepKind["PublishRelease"] = "publish-release";
|
|
17
|
+
PublishWorkflowStepKind["DeployProduction"] = "deploy-production";
|
|
17
18
|
PublishWorkflowStepKind["SaveNixDevenv"] = "save-nix-devenv";
|
|
18
19
|
})(PublishWorkflowStepKind || (PublishWorkflowStepKind = {}));
|
|
19
20
|
export function definePublishWorkflow(options = {}) {
|
|
@@ -26,6 +27,19 @@ export function definePublishWorkflow(options = {}) {
|
|
|
26
27
|
if (isSmoothBricksCodebasePackageName(options.repoName)) {
|
|
27
28
|
setupSteps.push({ kind: PublishWorkflowStepKind.BuildSelfHostedCli, name: '๐๏ธ Build self-hosted smoo' });
|
|
28
29
|
}
|
|
30
|
+
const releaseSteps = [
|
|
31
|
+
{
|
|
32
|
+
kind: PublishWorkflowStepKind.PublishRelease,
|
|
33
|
+
name: `๐ฆ Publish release (${versionMode})`,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
if (options.deploy === true) {
|
|
37
|
+
releaseSteps.push({
|
|
38
|
+
kind: PublishWorkflowStepKind.DeployProduction,
|
|
39
|
+
name: '๐ Deploy production',
|
|
40
|
+
condition: 'deploy-production',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
29
43
|
return {
|
|
30
44
|
steps: numberWorkflowSteps([
|
|
31
45
|
...setupSteps,
|
|
@@ -63,10 +77,7 @@ export function definePublishWorkflow(options = {}) {
|
|
|
63
77
|
name: `โ
Validate monorepo config (${versionMode})`,
|
|
64
78
|
condition: 'version-mode-not-none',
|
|
65
79
|
},
|
|
66
|
-
|
|
67
|
-
kind: PublishWorkflowStepKind.PublishRelease,
|
|
68
|
-
name: `๐ฆ Publish release (${versionMode})`,
|
|
69
|
-
},
|
|
80
|
+
...releaseSteps,
|
|
70
81
|
{
|
|
71
82
|
kind: PublishWorkflowStepKind.SaveNixDevenv,
|
|
72
83
|
name: '๐งน Cleanup and cache Nix/devenv',
|
|
@@ -84,7 +95,7 @@ export async function runPublishWorkflow(workflow, context) {
|
|
|
84
95
|
let failed = false;
|
|
85
96
|
let failure;
|
|
86
97
|
for (const step of workflow.steps) {
|
|
87
|
-
if (!shouldRunStep(step, version, failed)) {
|
|
98
|
+
if (!shouldRunStep(step, version, failed, context.inputs)) {
|
|
88
99
|
continue;
|
|
89
100
|
}
|
|
90
101
|
try {
|
|
@@ -132,6 +143,9 @@ export async function runPublishWorkflow(workflow, context) {
|
|
|
132
143
|
dryRun: context.inputs.dryRun,
|
|
133
144
|
});
|
|
134
145
|
break;
|
|
146
|
+
case PublishWorkflowStepKind.DeployProduction:
|
|
147
|
+
await context.callbacks.deployProduction();
|
|
148
|
+
break;
|
|
135
149
|
case PublishWorkflowStepKind.SaveNixDevenv:
|
|
136
150
|
await context.callbacks.saveNixDevenv(setupOutputs);
|
|
137
151
|
break;
|
|
@@ -147,10 +161,13 @@ export async function runPublishWorkflow(workflow, context) {
|
|
|
147
161
|
}
|
|
148
162
|
return { version, failed };
|
|
149
163
|
}
|
|
150
|
-
function shouldRunStep(step, version, failed) {
|
|
164
|
+
function shouldRunStep(step, version, failed, inputs) {
|
|
151
165
|
if (step.condition === 'version-mode-not-none') {
|
|
152
166
|
return version.mode !== 'none';
|
|
153
167
|
}
|
|
168
|
+
if (step.condition === 'deploy-production') {
|
|
169
|
+
return version.mode !== 'none' && inputs.deployEnvironment === 'production' && !inputs.dryRun;
|
|
170
|
+
}
|
|
154
171
|
if (step.condition === 'failure') {
|
|
155
172
|
return failed;
|
|
156
173
|
}
|
|
@@ -158,9 +175,17 @@ function shouldRunStep(step, version, failed) {
|
|
|
158
175
|
}
|
|
159
176
|
export function renderPublishWorkflowYaml(options = {}) {
|
|
160
177
|
const steps = definePublishWorkflow(options).steps;
|
|
161
|
-
return `${renderPublishWorkflowHeader()}${renderPublishWorkflowSteps(steps)}`;
|
|
178
|
+
return `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
|
|
162
179
|
}
|
|
163
|
-
function renderPublishWorkflowHeader() {
|
|
180
|
+
function renderPublishWorkflowHeader(options) {
|
|
181
|
+
const deployInput = options.deploy === true
|
|
182
|
+
? `
|
|
183
|
+
deploy_environment:
|
|
184
|
+
type: choice
|
|
185
|
+
description: Deploy live systems after a successful publish.
|
|
186
|
+
options: [none, production]
|
|
187
|
+
default: none`
|
|
188
|
+
: '';
|
|
164
189
|
return `name: Publish
|
|
165
190
|
|
|
166
191
|
on:
|
|
@@ -176,7 +201,7 @@ on:
|
|
|
176
201
|
dry_run:
|
|
177
202
|
type: boolean
|
|
178
203
|
description: Run release commands without writing versions, tags, publishes, or GitHub Releases.
|
|
179
|
-
default: false
|
|
204
|
+
default: false${deployInput}
|
|
180
205
|
|
|
181
206
|
permissions:
|
|
182
207
|
contents: write
|
|
@@ -199,12 +224,12 @@ jobs:
|
|
|
199
224
|
steps:
|
|
200
225
|
`;
|
|
201
226
|
}
|
|
202
|
-
function renderPublishWorkflowSteps(steps) {
|
|
227
|
+
function renderPublishWorkflowSteps(steps, options) {
|
|
203
228
|
const lines = [];
|
|
204
229
|
for (const step of steps) {
|
|
205
230
|
lines.push(...sectionLinesBefore(step));
|
|
206
231
|
lines.push(...commentLinesForStep(step));
|
|
207
|
-
lines.push(...yamlLinesForStep(step));
|
|
232
|
+
lines.push(...yamlLinesForStep(step, options));
|
|
208
233
|
lines.push('');
|
|
209
234
|
}
|
|
210
235
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
@@ -244,7 +269,7 @@ function commentLinesForStep(step) {
|
|
|
244
269
|
}
|
|
245
270
|
return [` # Step ${step.number}`];
|
|
246
271
|
}
|
|
247
|
-
function yamlLinesForStep(step) {
|
|
272
|
+
function yamlLinesForStep(step, options) {
|
|
248
273
|
switch (step.kind) {
|
|
249
274
|
case PublishWorkflowStepKind.Checkout:
|
|
250
275
|
return [
|
|
@@ -312,6 +337,8 @@ function yamlLinesForStep(step) {
|
|
|
312
337
|
' # Missing package names are bootstrapped locally before trust setup.',
|
|
313
338
|
` run: smoo release publish --bump "${githubExpression('inputs.bump')}" --dry-run "${githubExpression('inputs.dry_run')}"`,
|
|
314
339
|
];
|
|
340
|
+
case PublishWorkflowStepKind.DeployProduction:
|
|
341
|
+
return deployProductionStep(step, options);
|
|
315
342
|
case PublishWorkflowStepKind.SaveNixDevenv:
|
|
316
343
|
return [
|
|
317
344
|
` - name: ${step.name}`,
|
|
@@ -323,13 +350,30 @@ function yamlLinesForStep(step) {
|
|
|
323
350
|
];
|
|
324
351
|
}
|
|
325
352
|
}
|
|
326
|
-
function
|
|
353
|
+
function deployProductionStep(step, options) {
|
|
327
354
|
return [
|
|
328
355
|
` - name: ${step.name}`,
|
|
329
|
-
|
|
330
|
-
|
|
356
|
+
' if:',
|
|
357
|
+
" ${{ steps.version.outputs.mode != 'none' && inputs.deploy_environment == 'production' && inputs.dry_run !=",
|
|
358
|
+
" 'true' }}",
|
|
359
|
+
...deployEnvLines(options),
|
|
360
|
+
' run: smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
|
|
331
361
|
];
|
|
332
362
|
}
|
|
363
|
+
function deployEnvLines(options) {
|
|
364
|
+
if (options.deployProvider !== 'cloudflare') {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
return [
|
|
368
|
+
' env:',
|
|
369
|
+
' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
|
|
370
|
+
' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
function conditionalRunStep(step, run) {
|
|
374
|
+
const condition = "steps.version.outputs.mode != 'none'";
|
|
375
|
+
return [` - name: ${step.name}`, ` if: ${githubExpression(condition)}`, ` run: ${run}`];
|
|
376
|
+
}
|
|
333
377
|
function githubExpression(expression) {
|
|
334
378
|
return ['$', '{{ ', expression, ' }}'].join('');
|
|
335
379
|
}
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export declare function syncRootRuntimeVersions(root: string): Promise<void>;
|
|
2
|
+
type RuntimeTypesPinMode = 'major' | 'exact';
|
|
3
|
+
export declare function runtimeTypesRangeForPublishedVersions(packageName: string, runtimeVersion: string, pinMode: RuntimeTypesPinMode, versions: readonly string[]): string;
|
|
4
|
+
export {};
|
|
2
5
|
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/monorepo/runtime.ts"],"names":[],"mappings":"AAKA,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/monorepo/runtime.ts"],"names":[],"mappings":"AAKA,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCzE;AAED,KAAK,mBAAmB,GAAG,OAAO,GAAG,OAAO,CAAC;AAgB7C,wBAAgB,qCAAqC,CACnD,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,MAAM,CAsBR"}
|
package/dist/monorepo/runtime.js
CHANGED
|
@@ -21,13 +21,77 @@ export async function syncRootRuntimeVersions(root) {
|
|
|
21
21
|
changed = setStringProperty(engines, 'node', `>=${nodeMajor}.0.0`) || changed;
|
|
22
22
|
changed = setStringProperty(packageJson, 'packageManager', `bun@${bunVersion}`) || changed;
|
|
23
23
|
const devDependencies = getOrCreateRecord(packageJson, 'devDependencies');
|
|
24
|
-
changed =
|
|
25
|
-
|
|
24
|
+
changed =
|
|
25
|
+
setStringProperty(devDependencies, '@types/node', await runtimeTypesRange(root, '@types/node', nodeVersion, 'major')) || changed;
|
|
26
|
+
changed =
|
|
27
|
+
setStringProperty(devDependencies, '@types/bun', await runtimeTypesRange(root, '@types/bun', bunVersion, 'exact')) || changed;
|
|
26
28
|
if (changed) {
|
|
27
29
|
writeJsonObject(packageJsonPath, packageJson);
|
|
28
30
|
console.log('updated package.json runtime versions');
|
|
29
31
|
}
|
|
30
32
|
}
|
|
33
|
+
async function runtimeTypesRange(root, packageName, runtimeVersion, pinMode) {
|
|
34
|
+
const versionsText = await $ `bun pm view ${packageName} versions --json`.cwd(root).text();
|
|
35
|
+
const versions = JSON.parse(versionsText);
|
|
36
|
+
if (!Array.isArray(versions) || !versions.every((version) => typeof version === 'string')) {
|
|
37
|
+
throw new Error(`Unable to read published ${packageName} versions`);
|
|
38
|
+
}
|
|
39
|
+
return runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions);
|
|
40
|
+
}
|
|
41
|
+
export function runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions) {
|
|
42
|
+
const parsedRuntimeVersion = parseVersion(runtimeVersion);
|
|
43
|
+
if (!parsedRuntimeVersion) {
|
|
44
|
+
throw new Error(`Unable to parse runtime version ${runtimeVersion}`);
|
|
45
|
+
}
|
|
46
|
+
if (pinMode === 'major') {
|
|
47
|
+
const runtimeMajor = parsedRuntimeVersion[0].toString();
|
|
48
|
+
if (latestVersion(versions.filter((version) => versionMajor(version) === runtimeMajor))) {
|
|
49
|
+
return `~${runtimeMajor}.0.0`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (versions.includes(runtimeVersion)) {
|
|
53
|
+
return runtimeVersion;
|
|
54
|
+
}
|
|
55
|
+
const latest = latestVersion(versions);
|
|
56
|
+
if (!latest) {
|
|
57
|
+
throw new Error(`Unable to find any published ${packageName} versions`);
|
|
58
|
+
}
|
|
59
|
+
console.warn(`${packageName} has no published types for runtime ${runtimeVersion}; using ${latest}`);
|
|
60
|
+
return pinMode === 'major' ? `~${latest}` : latest;
|
|
61
|
+
}
|
|
62
|
+
function latestVersion(versions) {
|
|
63
|
+
let latest = null;
|
|
64
|
+
for (const version of versions) {
|
|
65
|
+
if (!parseVersion(version)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!latest || compareVersions(version, latest) > 0) {
|
|
69
|
+
latest = version;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return latest;
|
|
73
|
+
}
|
|
74
|
+
function compareVersions(left, right) {
|
|
75
|
+
const leftParts = parseVersion(left);
|
|
76
|
+
const rightParts = parseVersion(right);
|
|
77
|
+
if (!leftParts || !rightParts) {
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
for (let index = 0; index < leftParts.length; index++) {
|
|
81
|
+
const diff = leftParts[index] - rightParts[index];
|
|
82
|
+
if (diff !== 0) {
|
|
83
|
+
return diff;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
function versionMajor(version) {
|
|
89
|
+
return parseVersion(version)?.[0].toString() ?? null;
|
|
90
|
+
}
|
|
91
|
+
function parseVersion(version) {
|
|
92
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
93
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
94
|
+
}
|
|
31
95
|
function runtimeCommand(root, name) {
|
|
32
96
|
const candidates = [
|
|
33
97
|
join(root, 'tooling', 'direnv', '.devenv', 'profile', 'bin', name),
|
|
@@ -42,6 +42,10 @@ formatters:
|
|
|
42
42
|
- '!*.nix'
|
|
43
43
|
# Bun lockfiles are generated and should not be rewritten by Prettier
|
|
44
44
|
- '!bun.lock'
|
|
45
|
+
# Binary artifacts: Prettier reads them as UTF-8 and can corrupt them
|
|
46
|
+
# by replacing invalid byte sequences.
|
|
47
|
+
- '!*.wasm'
|
|
48
|
+
- '!*.wasm.sha256'
|
|
45
49
|
|
|
46
50
|
# Alejandra for Nix files
|
|
47
51
|
alejandra:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smoothbricks/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SmoothBricks monorepo automation CLI",
|
|
6
6
|
"bin": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@arethetypeswrong/cli": "^0.18.2",
|
|
48
|
-
"@smoothbricks/nx-plugin": "0.2.
|
|
48
|
+
"@smoothbricks/nx-plugin": "0.2.4",
|
|
49
49
|
"@smoothbricks/validation": "0.1.1",
|
|
50
50
|
"commander": "^14.0.3",
|
|
51
51
|
"publint": "^0.3.18",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"test": {
|
|
73
73
|
"executor": "@smoothbricks/nx-plugin:bounded-exec",
|
|
74
74
|
"options": {
|
|
75
|
-
"command": "bun test",
|
|
75
|
+
"command": "bun test --timeout=30000",
|
|
76
76
|
"cwd": "{projectRoot}",
|
|
77
77
|
"timeoutMs": 120000,
|
|
78
78
|
"killAfterMs": 1e4
|
package/src/cli.ts
CHANGED
|
@@ -298,18 +298,48 @@ function buildProgram(): Command {
|
|
|
298
298
|
.requiredOption('--target <target>')
|
|
299
299
|
.option('--name <name>')
|
|
300
300
|
.option('--step <step>')
|
|
301
|
-
.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
.option('--mode <mode>', 'auto, affected, or run-many', 'auto')
|
|
302
|
+
.option('--configuration <configuration>')
|
|
303
|
+
.action(
|
|
304
|
+
async (options: {
|
|
305
|
+
target: string;
|
|
306
|
+
name?: string;
|
|
307
|
+
step?: string;
|
|
308
|
+
mode?: 'auto' | 'affected' | 'run-many';
|
|
309
|
+
configuration?: string;
|
|
310
|
+
}) => {
|
|
311
|
+
const { githubCiNxSmart } = await import('./github-ci/index.js');
|
|
312
|
+
await githubCiNxSmart(await findRepoRoot(), options);
|
|
313
|
+
},
|
|
314
|
+
);
|
|
305
315
|
githubCi
|
|
306
316
|
.command('nx-run-many')
|
|
307
317
|
.requiredOption('--targets <targets>')
|
|
308
318
|
.option('--projects <projects>')
|
|
309
|
-
.
|
|
319
|
+
.option('--configuration <configuration>')
|
|
320
|
+
.action(async (options: { targets: string; projects?: string; configuration?: string }) => {
|
|
310
321
|
const { githubCiNxRunMany } = await import('./github-ci/index.js');
|
|
311
322
|
await githubCiNxRunMany(await findRepoRoot(), options);
|
|
312
323
|
});
|
|
324
|
+
githubCi
|
|
325
|
+
.command('nx-deploy')
|
|
326
|
+
.requiredOption('--configuration <configuration>')
|
|
327
|
+
.option('--mode <mode>', 'auto, affected, or run-many', 'run-many')
|
|
328
|
+
.option('--name <name>')
|
|
329
|
+
.option('--step <step>')
|
|
330
|
+
.option('--verify', 'run build, lint, and test before deploy')
|
|
331
|
+
.action(
|
|
332
|
+
async (options: {
|
|
333
|
+
configuration: string;
|
|
334
|
+
mode?: 'auto' | 'affected' | 'run-many';
|
|
335
|
+
name?: string;
|
|
336
|
+
step?: string;
|
|
337
|
+
verify?: boolean;
|
|
338
|
+
}) => {
|
|
339
|
+
const { githubCiNxDeploy } = await import('./github-ci/index.js');
|
|
340
|
+
await githubCiNxDeploy(await findRepoRoot(), options);
|
|
341
|
+
},
|
|
342
|
+
);
|
|
313
343
|
|
|
314
344
|
return program;
|
|
315
345
|
}
|
package/src/github-ci/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { $ } from 'bun';
|
|
5
5
|
import { decode, run, runStatus } from '../lib/run.js';
|
|
6
6
|
|
|
7
|
+
type NxSmartMode = 'auto' | 'affected' | 'run-many';
|
|
8
|
+
|
|
7
9
|
export async function cleanupGithubCiCache(root: string): Promise<void> {
|
|
8
10
|
const githubOutput = process.env.GITHUB_OUTPUT;
|
|
9
11
|
const markCacheReady = async (ready: boolean): Promise<void> => {
|
|
@@ -113,17 +115,17 @@ async function addReferencesFrom(roots: Set<string>, path: string, cwd: string):
|
|
|
113
115
|
|
|
114
116
|
export async function githubCiNxSmart(
|
|
115
117
|
root: string,
|
|
116
|
-
options: { target: string; name?: string; step?: string },
|
|
118
|
+
options: { target: string; name?: string; step?: string; mode?: NxSmartMode; configuration?: string },
|
|
117
119
|
): Promise<void> {
|
|
118
120
|
const name = options.name ?? options.target;
|
|
119
121
|
const step = options.step ?? '';
|
|
120
122
|
await createGithubStatus(name, step);
|
|
121
|
-
const mode =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
const mode = resolveNxSmartMode(options.mode ?? 'auto');
|
|
124
|
+
const nxArgs = mode === 'run-many' ? ['run-many', '-t', options.target] : ['affected', '-t', options.target];
|
|
125
|
+
if (options.configuration) {
|
|
126
|
+
nxArgs.push(`--configuration=${options.configuration}`);
|
|
127
|
+
}
|
|
128
|
+
nxArgs.push('--parallel=5');
|
|
127
129
|
const status = await runStatus('nx', nxArgs, root);
|
|
128
130
|
await updateGithubStatus(name, status === 0 ? 'success' : 'failure', step);
|
|
129
131
|
if (status !== 0) {
|
|
@@ -131,14 +133,98 @@ export async function githubCiNxSmart(
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
export async function githubCiNxRunMany(
|
|
136
|
+
export async function githubCiNxRunMany(
|
|
137
|
+
root: string,
|
|
138
|
+
options: { targets: string; projects?: string; configuration?: string },
|
|
139
|
+
): Promise<void> {
|
|
135
140
|
const nxArgs = ['run-many', '-t', options.targets, '--parallel=5'];
|
|
136
141
|
if (options.projects) {
|
|
137
142
|
nxArgs.push(`--projects=${options.projects}`);
|
|
138
143
|
}
|
|
144
|
+
if (options.configuration) {
|
|
145
|
+
nxArgs.push(`--configuration=${options.configuration}`);
|
|
146
|
+
}
|
|
139
147
|
await run('nx', nxArgs, root);
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
export async function githubCiNxDeploy(
|
|
151
|
+
root: string,
|
|
152
|
+
options: { configuration: string; mode?: NxSmartMode; name?: string; step?: string; verify?: boolean },
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const name = options.name ?? `Deploy ${options.configuration}`;
|
|
155
|
+
const step = options.step ?? '';
|
|
156
|
+
await createGithubStatus(name, step);
|
|
157
|
+
const mode = resolveNxSmartMode(options.mode ?? 'run-many');
|
|
158
|
+
const projects = await deployProjectsWithConfiguration(root, options.configuration, mode);
|
|
159
|
+
if (projects.length === 0) {
|
|
160
|
+
console.log(`No ${mode} deploy projects with configuration ${options.configuration}; skipping.`);
|
|
161
|
+
await updateGithubStatus(name, 'success', step);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const projectList = projects.join(',');
|
|
166
|
+
const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
|
|
167
|
+
for (const target of targets) {
|
|
168
|
+
const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, '--parallel=5'];
|
|
169
|
+
if (target === 'deploy') {
|
|
170
|
+
nxArgs.push(`--configuration=${options.configuration}`);
|
|
171
|
+
}
|
|
172
|
+
const status = await runStatus('nx', nxArgs, root);
|
|
173
|
+
if (status !== 0) {
|
|
174
|
+
await updateGithubStatus(name, 'failure', step);
|
|
175
|
+
throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
await updateGithubStatus(name, 'success', step);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveNxSmartMode(mode: NxSmartMode): 'affected' | 'run-many' {
|
|
182
|
+
if (mode === 'affected' || mode === 'run-many') {
|
|
183
|
+
return mode;
|
|
184
|
+
}
|
|
185
|
+
return process.env.GITHUB_EVENT_NAME === 'push' && process.env.GITHUB_REF_NAME === 'main' ? 'run-many' : 'affected';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function deployProjectsWithConfiguration(
|
|
189
|
+
root: string,
|
|
190
|
+
configuration: string,
|
|
191
|
+
mode: 'affected' | 'run-many',
|
|
192
|
+
): Promise<string[]> {
|
|
193
|
+
const listArgs =
|
|
194
|
+
mode === 'affected'
|
|
195
|
+
? ['show', 'projects', '--affected', '--withTarget', 'deploy', '--json']
|
|
196
|
+
: ['show', 'projects', '--withTarget', 'deploy', '--json'];
|
|
197
|
+
const result = await $`nx ${listArgs}`.cwd(root).quiet();
|
|
198
|
+
const candidates = nxProjectList(decode(result.stdout));
|
|
199
|
+
const projects: string[] = [];
|
|
200
|
+
for (const project of candidates) {
|
|
201
|
+
if (await deployTargetHasConfiguration(root, project, configuration)) {
|
|
202
|
+
projects.push(project);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return projects.sort((a, b) => a.localeCompare(b));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function deployTargetHasConfiguration(root: string, project: string, configuration: string): Promise<boolean> {
|
|
209
|
+
const result = await $`nx show project ${project} --json`.cwd(root).quiet();
|
|
210
|
+
const parsed: unknown = JSON.parse(decode(result.stdout));
|
|
211
|
+
const targets = recordValue(parsed)?.targets;
|
|
212
|
+
const deploy = recordValue(targets)?.deploy;
|
|
213
|
+
const configurations = recordValue(deploy)?.configurations;
|
|
214
|
+
return recordValue(configurations)?.[configuration] !== undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function nxProjectList(output: string): string[] {
|
|
218
|
+
const parsed: unknown = JSON.parse(output);
|
|
219
|
+
return Array.isArray(parsed) ? parsed.filter((project): project is string => typeof project === 'string') : [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
223
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
224
|
+
? (value as Record<string, unknown>)
|
|
225
|
+
: undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
142
228
|
async function createGithubStatus(name: string, step: string): Promise<void> {
|
|
143
229
|
await postGithubStatus(name, 'pending', `Running ${name}...`, step);
|
|
144
230
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CiWorkflowStepKind, defineCiWorkflow, renderCiWorkflowYaml } from '../ci-workflow.js';
|
|
5
|
+
|
|
6
|
+
describe('CI workflow definition', () => {
|
|
7
|
+
it('renders the checked-in local CI workflow copy', async () => {
|
|
8
|
+
const rendered = renderCiWorkflowYaml({ deploy: false, pushBranches: ['main'] });
|
|
9
|
+
const packageRoot = join(import.meta.dir, '..', '..', '..');
|
|
10
|
+
|
|
11
|
+
await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/ci.yml'), 'utf8')).resolves.toBe(rendered);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('inserts deploy after tests and renumbers following deeplink steps', () => {
|
|
15
|
+
const steps = defineCiWorkflow({ deploy: true, pushBranches: ['main'] });
|
|
16
|
+
const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
|
|
17
|
+
|
|
18
|
+
expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 9]);
|
|
19
|
+
expect(rendered).toContain('- name: ๐ Deploy Staging');
|
|
20
|
+
expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
|
|
21
|
+
expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
|
|
22
|
+
expect(rendered).toContain(
|
|
23
|
+
'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 9',
|
|
24
|
+
);
|
|
25
|
+
expect(rendered).toContain("# Step 10\n # Nx's database cache needs artifact files");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
|
|
29
|
+
const rendered = renderCiWorkflowYaml({ deploy: true, deployProvider: 'cloudflare', pushBranches: ['main'] });
|
|
30
|
+
|
|
31
|
+
expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
|
|
32
|
+
expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -28,6 +28,61 @@ describe('publish workflow definition', () => {
|
|
|
28
28
|
expect(rendered).toContain('packages must already exist on npm and use trusted publishing/OIDC');
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
it('omits production deploy controls when no production deploy target exists', () => {
|
|
32
|
+
const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
33
|
+
|
|
34
|
+
expect(rendered).not.toContain('deploy_environment');
|
|
35
|
+
expect(rendered).not.toContain('Deploy production');
|
|
36
|
+
expect(rendered).not.toContain('nx-deploy');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders production deploy controls for repos with production deploy targets', () => {
|
|
40
|
+
const rendered = renderPublishWorkflowYaml({ deploy: true, repoName: '@smoothbricks/codebase' });
|
|
41
|
+
|
|
42
|
+
expect(rendered).toContain('deploy_environment:');
|
|
43
|
+
expect(rendered).toContain('- name: ๐ Deploy production');
|
|
44
|
+
expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
|
|
45
|
+
expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
|
|
46
|
+
expect(rendered).toContain(
|
|
47
|
+
'smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('adds Cloudflare credentials for Wrangler-backed production deploys', () => {
|
|
52
|
+
const rendered = renderPublishWorkflowYaml({
|
|
53
|
+
deploy: true,
|
|
54
|
+
deployProvider: 'cloudflare',
|
|
55
|
+
repoName: '@smoothbricks/codebase',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
|
|
59
|
+
expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('deploys production only after a real publish when requested', async () => {
|
|
63
|
+
const deployed = await publishWorkflowScenario({
|
|
64
|
+
deploy: true,
|
|
65
|
+
repairs: [],
|
|
66
|
+
current: [],
|
|
67
|
+
bump: 'auto',
|
|
68
|
+
deployEnvironment: 'production',
|
|
69
|
+
dryRun: false,
|
|
70
|
+
version: { mode: 'new', projects: ['app'] },
|
|
71
|
+
}).run();
|
|
72
|
+
const dryRun = await publishWorkflowScenario({
|
|
73
|
+
deploy: true,
|
|
74
|
+
repairs: [],
|
|
75
|
+
current: [],
|
|
76
|
+
bump: 'auto',
|
|
77
|
+
deployEnvironment: 'production',
|
|
78
|
+
dryRun: true,
|
|
79
|
+
version: { mode: 'new', projects: ['app'] },
|
|
80
|
+
}).run();
|
|
81
|
+
|
|
82
|
+
expect(deployed.productionDeployed).toBe(true);
|
|
83
|
+
expect(dryRun.productionDeployed).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
31
86
|
it('bootstraps the self-hosted CLI before release commands only for the SmoothBricks repo', async () => {
|
|
32
87
|
const smoothbricks = await publishWorkflowScenario({
|
|
33
88
|
repoName: '@smoothbricks/codebase',
|
|
@@ -143,10 +198,12 @@ interface ReleaseGap {
|
|
|
143
198
|
}
|
|
144
199
|
|
|
145
200
|
interface WorkflowScenarioConfig {
|
|
201
|
+
deploy?: boolean;
|
|
146
202
|
repoName?: string;
|
|
147
203
|
repairs: ReleaseGap[];
|
|
148
204
|
current: ReleaseGap[];
|
|
149
205
|
bump: PublishWorkflowBump;
|
|
206
|
+
deployEnvironment?: 'none' | 'production';
|
|
150
207
|
dryRun: boolean;
|
|
151
208
|
version: PublishWorkflowVersionOutputs;
|
|
152
209
|
}
|
|
@@ -161,6 +218,7 @@ interface WorkflowOutcome {
|
|
|
161
218
|
repairBuildArtifacts: string[];
|
|
162
219
|
validation: { checks: number; builds: string[]; lints: string[]; tests: string[]; validates: number };
|
|
163
220
|
publishRan: boolean;
|
|
221
|
+
productionDeployed: boolean;
|
|
164
222
|
publishSawValidatedRelease: boolean;
|
|
165
223
|
publishCompletedTags: string[];
|
|
166
224
|
remainingDurableGaps: string[];
|
|
@@ -170,8 +228,8 @@ function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promi
|
|
|
170
228
|
return {
|
|
171
229
|
async run() {
|
|
172
230
|
const state = new WorkflowScenarioState(config);
|
|
173
|
-
await runPublishWorkflow(definePublishWorkflow({ repoName: config.repoName }), {
|
|
174
|
-
inputs: { bump: config.bump, dryRun: config.dryRun },
|
|
231
|
+
await runPublishWorkflow(definePublishWorkflow({ deploy: config.deploy, repoName: config.repoName }), {
|
|
232
|
+
inputs: { bump: config.bump, deployEnvironment: config.deployEnvironment ?? 'none', dryRun: config.dryRun },
|
|
175
233
|
callbacks: state.callbacks(),
|
|
176
234
|
});
|
|
177
235
|
return state.outcome();
|
|
@@ -186,6 +244,7 @@ class WorkflowScenarioState {
|
|
|
186
244
|
private repairObservedSelfHostedCli = false;
|
|
187
245
|
private versionObservedSelfHostedCli = false;
|
|
188
246
|
private publishReleaseRan = false;
|
|
247
|
+
private productionDeployRan = false;
|
|
189
248
|
private publishSawValidation = false;
|
|
190
249
|
private readonly repaired = new Set<string>();
|
|
191
250
|
private readonly repairBuilds = new Set<string>();
|
|
@@ -275,6 +334,9 @@ class WorkflowScenarioState {
|
|
|
275
334
|
}
|
|
276
335
|
}
|
|
277
336
|
},
|
|
337
|
+
deployProduction: async () => {
|
|
338
|
+
this.productionDeployRan = true;
|
|
339
|
+
},
|
|
278
340
|
saveNixDevenv: async () => {},
|
|
279
341
|
};
|
|
280
342
|
}
|
|
@@ -296,6 +358,7 @@ class WorkflowScenarioState {
|
|
|
296
358
|
validates: this.validationState.validates,
|
|
297
359
|
},
|
|
298
360
|
publishRan: this.publishReleaseRan,
|
|
361
|
+
productionDeployed: this.productionDeployRan,
|
|
299
362
|
publishSawValidatedRelease: this.publishSawValidation,
|
|
300
363
|
publishCompletedTags: [...this.publishedCurrent].sort(),
|
|
301
364
|
remainingDurableGaps: [...this.durableGaps].sort(),
|