@smoothbricks/cli 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +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/lib/run.d.ts +1 -0
- package/dist/lib/run.d.ts.map +1 -1
- package/dist/lib/run.js +11 -0
- 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/nx-sync.d.ts.map +1 -1
- package/dist/monorepo/nx-sync.js +18 -4
- package/dist/monorepo/package-hygiene.d.ts +5 -0
- package/dist/monorepo/package-hygiene.d.ts.map +1 -1
- package/dist/monorepo/package-hygiene.js +18 -4
- package/dist/monorepo/package-policy.d.ts.map +1 -1
- package/dist/monorepo/package-policy.js +4 -2
- package/dist/monorepo/packed-package.js +5 -4
- package/dist/monorepo/packs/index.js +6 -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/managed/raw/git-format-staged.yml +4 -0
- package/package.json +2 -2
- package/src/cli.ts +35 -5
- package/src/github-ci/index.ts +94 -8
- package/src/lib/run.ts +13 -0
- 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/nx-sync.ts +18 -4
- package/src/monorepo/package-hygiene.test.ts +54 -1
- package/src/monorepo/package-hygiene.ts +34 -4
- package/src/monorepo/package-policy.test.ts +16 -6
- package/src/monorepo/package-policy.ts +4 -2
- package/src/monorepo/packed-package.ts +5 -5
- package/src/monorepo/packs/index.ts +6 -2
- package/src/monorepo/publish-workflow.ts +73 -16
- package/managed/templates/github/workflows/ci.yml +0 -102
|
@@ -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(),
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
export enum CiWorkflowStepKind {
|
|
2
|
+
Checkout = 'checkout',
|
|
3
|
+
SetupDevenv = 'setup-devenv',
|
|
4
|
+
SetNxShas = 'set-nx-shas',
|
|
5
|
+
RestoreNxCache = 'restore-nx-cache',
|
|
6
|
+
Build = 'build',
|
|
7
|
+
Lint = 'lint',
|
|
8
|
+
UnitTests = 'unit-tests',
|
|
9
|
+
Deploy = 'deploy',
|
|
10
|
+
SaveNxCache = 'save-nx-cache',
|
|
11
|
+
UploadTraceDbs = 'upload-trace-dbs',
|
|
12
|
+
SaveNixDevenv = 'save-nix-devenv',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CiWorkflowStep {
|
|
16
|
+
kind: CiWorkflowStepKind;
|
|
17
|
+
name: string;
|
|
18
|
+
number: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CiWorkflowDefinitionOptions {
|
|
22
|
+
deploy: boolean;
|
|
23
|
+
deployProvider?: 'cloudflare';
|
|
24
|
+
pushBranches: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type CiWorkflowStepInput = Omit<CiWorkflowStep, 'number'>;
|
|
28
|
+
|
|
29
|
+
export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkflowStep[] {
|
|
30
|
+
const steps: CiWorkflowStepInput[] = [
|
|
31
|
+
{ kind: CiWorkflowStepKind.Checkout, name: '📥 Checkout' },
|
|
32
|
+
{ kind: CiWorkflowStepKind.SetupDevenv, name: '🧱 Setup Nix/devenv' },
|
|
33
|
+
{ kind: CiWorkflowStepKind.SetNxShas, name: '🧭 Set Nx SHAs' },
|
|
34
|
+
{ kind: CiWorkflowStepKind.RestoreNxCache, name: '🧠 Restore Nx cache' },
|
|
35
|
+
{ kind: CiWorkflowStepKind.Build, name: '🔨 Build' },
|
|
36
|
+
{ kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
|
|
37
|
+
{ kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
|
|
38
|
+
];
|
|
39
|
+
if (options.deploy) {
|
|
40
|
+
steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Staging' });
|
|
41
|
+
}
|
|
42
|
+
steps.push(
|
|
43
|
+
{ kind: CiWorkflowStepKind.SaveNxCache, name: '💾 Save Nx cache' },
|
|
44
|
+
{ kind: CiWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs' },
|
|
45
|
+
{ kind: CiWorkflowStepKind.SaveNixDevenv, name: '🧹 Cleanup and cache Nix/devenv' },
|
|
46
|
+
);
|
|
47
|
+
return steps.map((step, index) => ({ ...step, number: index + 2 }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function renderCiWorkflowYaml(options: CiWorkflowDefinitionOptions): string {
|
|
51
|
+
const steps = defineCiWorkflow(options);
|
|
52
|
+
return `${renderCiWorkflowHeader(options)}${renderCiWorkflowSteps(steps, options)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderCiWorkflowHeader(options: CiWorkflowDefinitionOptions): string {
|
|
56
|
+
return `name: CI
|
|
57
|
+
|
|
58
|
+
on:
|
|
59
|
+
push:
|
|
60
|
+
branches:
|
|
61
|
+
${renderYamlList(options.pushBranches, 6)}
|
|
62
|
+
pull_request:
|
|
63
|
+
|
|
64
|
+
permissions:
|
|
65
|
+
actions: read
|
|
66
|
+
contents: read
|
|
67
|
+
statuses: write
|
|
68
|
+
|
|
69
|
+
defaults:
|
|
70
|
+
run:
|
|
71
|
+
working-directory: tooling/direnv
|
|
72
|
+
|
|
73
|
+
jobs:
|
|
74
|
+
main:
|
|
75
|
+
name: Validate
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
timeout-minutes: 45
|
|
78
|
+
env:
|
|
79
|
+
NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
|
|
80
|
+
GH_TOKEN: ${githubExpression('github.token')}
|
|
81
|
+
steps:
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function githubExpression(expression: string): string {
|
|
86
|
+
return `$${`{{ ${expression} }}`}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderCiWorkflowSteps(steps: CiWorkflowStep[], options: CiWorkflowDefinitionOptions): string {
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
for (const step of steps) {
|
|
92
|
+
lines.push(...sectionLinesBefore(step));
|
|
93
|
+
lines.push(...commentLinesForStep(step));
|
|
94
|
+
lines.push(...yamlLinesForStep(step, options));
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sectionLinesBefore(step: CiWorkflowStep): string[] {
|
|
101
|
+
if (step.kind === CiWorkflowStepKind.SetNxShas) {
|
|
102
|
+
return [' # --- Nx -----------------------------------------------------------------', ''];
|
|
103
|
+
}
|
|
104
|
+
if (step.kind === CiWorkflowStepKind.SaveNixDevenv) {
|
|
105
|
+
return [' # --- Cleanup ------------------------------------------------------------', ''];
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function commentLinesForStep(step: CiWorkflowStep): string[] {
|
|
111
|
+
if (step.kind === CiWorkflowStepKind.Checkout) {
|
|
112
|
+
return [' # Step 1: GitHub adds "Set up job" automatically', ' # Step 2'];
|
|
113
|
+
}
|
|
114
|
+
if (step.kind === CiWorkflowStepKind.SetupDevenv) {
|
|
115
|
+
return [
|
|
116
|
+
' # Step 3. Composite action internals do not affect top-level job step',
|
|
117
|
+
' # anchors; update the nx-smart --step values below if top-level steps move.',
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
if (step.kind === CiWorkflowStepKind.SetNxShas) {
|
|
121
|
+
return [` # Step ${step.number}`, ' # Sets the base and head SHAs required for the nx affected commands'];
|
|
122
|
+
}
|
|
123
|
+
if (step.kind === CiWorkflowStepKind.SaveNxCache) {
|
|
124
|
+
return [
|
|
125
|
+
` # Step ${step.number}`,
|
|
126
|
+
" # Nx's database cache needs artifact files and .nx/workspace-data DB",
|
|
127
|
+
' # metadata restored together; GitHub Actions cache is only the archive',
|
|
128
|
+
' # transport. Save runs only after prior required steps succeed on the default',
|
|
129
|
+
' # branch, so PRs may restore shared cache but cannot publish it.',
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
return [` # Step ${step.number}`];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOptions): string[] {
|
|
136
|
+
switch (step.kind) {
|
|
137
|
+
case CiWorkflowStepKind.Checkout:
|
|
138
|
+
return [
|
|
139
|
+
` - name: ${step.name}`,
|
|
140
|
+
' uses: actions/checkout@v6.0.2',
|
|
141
|
+
' with:',
|
|
142
|
+
' filter: blob:none',
|
|
143
|
+
' fetch-depth: 0',
|
|
144
|
+
];
|
|
145
|
+
case CiWorkflowStepKind.SetupDevenv:
|
|
146
|
+
return [` - name: ${step.name}`, ' id: setup', ' uses: ./.github/actions/setup-devenv'];
|
|
147
|
+
case CiWorkflowStepKind.SetNxShas:
|
|
148
|
+
return [
|
|
149
|
+
` - name: ${step.name}`,
|
|
150
|
+
' uses: nrwl/nx-set-shas@v5.0.1',
|
|
151
|
+
' with:',
|
|
152
|
+
' workflow-id: ci.yml',
|
|
153
|
+
];
|
|
154
|
+
case CiWorkflowStepKind.RestoreNxCache:
|
|
155
|
+
return [` - name: ${step.name}`, ' id: nx-cache', ' uses: ./.github/actions/cache-nx'];
|
|
156
|
+
case CiWorkflowStepKind.Build:
|
|
157
|
+
return nxSmartStep(step, 'build', 'Build');
|
|
158
|
+
case CiWorkflowStepKind.Lint:
|
|
159
|
+
return nxSmartStep(step, 'lint', 'Lint');
|
|
160
|
+
case CiWorkflowStepKind.UnitTests:
|
|
161
|
+
return nxSmartStep(step, 'test', 'Unit Tests');
|
|
162
|
+
case CiWorkflowStepKind.Deploy:
|
|
163
|
+
return [
|
|
164
|
+
` - name: ${step.name}`,
|
|
165
|
+
' if:',
|
|
166
|
+
" ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
|
|
167
|
+
' github.event.repository.default_branch) }}',
|
|
168
|
+
...deployEnvLines(options),
|
|
169
|
+
` run: smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step ${step.number}`,
|
|
170
|
+
];
|
|
171
|
+
case CiWorkflowStepKind.SaveNxCache:
|
|
172
|
+
return [
|
|
173
|
+
` - name: ${step.name}`,
|
|
174
|
+
' if:',
|
|
175
|
+
" ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
|
|
176
|
+
" github.event.repository.default_branch) && steps.nx-cache.outputs.cache-hit != 'true' }}",
|
|
177
|
+
' uses: actions/cache/save@v5.0.5',
|
|
178
|
+
' with:',
|
|
179
|
+
' path: |',
|
|
180
|
+
' .nx/cache',
|
|
181
|
+
' .nx/workspace-data/*.db*',
|
|
182
|
+
' key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}',
|
|
183
|
+
];
|
|
184
|
+
case CiWorkflowStepKind.UploadTraceDbs:
|
|
185
|
+
return [
|
|
186
|
+
` - name: ${step.name}`,
|
|
187
|
+
' if: ${{ always() }}',
|
|
188
|
+
' uses: actions/upload-artifact@v7.0.1',
|
|
189
|
+
' with:',
|
|
190
|
+
' name: trace-results-${{ github.run_id }}',
|
|
191
|
+
' path: packages/*/.trace-results.db',
|
|
192
|
+
' if-no-files-found: ignore',
|
|
193
|
+
' retention-days: 14',
|
|
194
|
+
' include-hidden-files: true',
|
|
195
|
+
];
|
|
196
|
+
case CiWorkflowStepKind.SaveNixDevenv:
|
|
197
|
+
return [
|
|
198
|
+
` - name: ${step.name}`,
|
|
199
|
+
' if: ${{ always() }}',
|
|
200
|
+
' uses: ./.github/actions/save-nix-devenv',
|
|
201
|
+
' with:',
|
|
202
|
+
' nix-cache-hit: ${{ steps.setup.outputs.nix-cache-hit }}',
|
|
203
|
+
' devenv-cache-hit: ${{ steps.setup.outputs.devenv-cache-hit }}',
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function deployEnvLines(options: CiWorkflowDefinitionOptions): string[] {
|
|
209
|
+
if (options.deployProvider !== 'cloudflare') {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
return [
|
|
213
|
+
' env:',
|
|
214
|
+
' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
|
|
215
|
+
' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function nxSmartStep(step: CiWorkflowStep, target: string, name: string): string[] {
|
|
220
|
+
return [
|
|
221
|
+
` - name: ${step.name}`,
|
|
222
|
+
` run: smoo github-ci nx-smart --target ${target} --name "${name}" --step ${step.number}`,
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderYamlList(values: string[], spaces: number): string {
|
|
227
|
+
const indent = ' '.repeat(spaces);
|
|
228
|
+
return values.map((value) => `${indent}- ${value}`).join('\n');
|
|
229
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { dirname, join, resolve } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
|
|
6
|
+
import { renderCiWorkflowYaml } from './ci-workflow.js';
|
|
5
7
|
import { renderPublishWorkflowYaml } from './publish-workflow.js';
|
|
6
8
|
|
|
7
9
|
type ManagedKind = 'raw' | 'template' | 'generated';
|
|
@@ -21,10 +23,20 @@ export interface FileResult {
|
|
|
21
23
|
|
|
22
24
|
interface ManagedFileContext {
|
|
23
25
|
hasReleasePackages: boolean;
|
|
26
|
+
hasStagingDeployTargets: boolean;
|
|
27
|
+
hasProductionDeployTargets: boolean;
|
|
28
|
+
stagingDeployProvider?: 'cloudflare';
|
|
29
|
+
productionDeployProvider?: 'cloudflare';
|
|
30
|
+
ciPushBranches: string[];
|
|
24
31
|
nodeModulesCacheKey: string;
|
|
25
32
|
repoName: string;
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
interface DeployTargetInfo {
|
|
36
|
+
exists: boolean;
|
|
37
|
+
provider?: 'cloudflare';
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
const managedFiles: ManagedFile[] = [
|
|
29
41
|
{
|
|
30
42
|
kind: 'raw',
|
|
@@ -56,8 +68,8 @@ const managedFiles: ManagedFile[] = [
|
|
|
56
68
|
target: '.git-format-staged.yml',
|
|
57
69
|
},
|
|
58
70
|
{
|
|
59
|
-
kind: '
|
|
60
|
-
source: '
|
|
71
|
+
kind: 'generated',
|
|
72
|
+
source: 'ci-workflow',
|
|
61
73
|
target: '.github/workflows/ci.yml',
|
|
62
74
|
},
|
|
63
75
|
{
|
|
@@ -106,7 +118,7 @@ function applyManagedFile(
|
|
|
106
118
|
mode: 'update' | 'check' | 'diff',
|
|
107
119
|
context: ManagedFileContext,
|
|
108
120
|
): FileResult {
|
|
109
|
-
if (file.releasePackagesOnly === true && !context.hasReleasePackages) {
|
|
121
|
+
if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
|
|
110
122
|
return { target: file.target, action: 'skipped' };
|
|
111
123
|
}
|
|
112
124
|
const target = resolve(root, file.target);
|
|
@@ -138,8 +150,19 @@ function applyManagedFile(
|
|
|
138
150
|
|
|
139
151
|
function getManagedContent(file: ManagedFile, context: ManagedFileContext): string {
|
|
140
152
|
if (file.kind === 'generated') {
|
|
153
|
+
if (file.source === 'ci-workflow') {
|
|
154
|
+
return renderCiWorkflowYaml({
|
|
155
|
+
deploy: context.hasStagingDeployTargets,
|
|
156
|
+
deployProvider: context.stagingDeployProvider,
|
|
157
|
+
pushBranches: context.ciPushBranches,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
141
160
|
if (file.source === 'publish-workflow') {
|
|
142
|
-
return renderPublishWorkflowYaml({
|
|
161
|
+
return renderPublishWorkflowYaml({
|
|
162
|
+
deploy: context.hasProductionDeployTargets,
|
|
163
|
+
deployProvider: context.productionDeployProvider,
|
|
164
|
+
repoName: context.repoName,
|
|
165
|
+
});
|
|
143
166
|
}
|
|
144
167
|
throw new Error(`Unknown generated managed file source ${file.source}`);
|
|
145
168
|
}
|
|
@@ -155,18 +178,105 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
|
|
|
155
178
|
function getManagedFileContext(root: string): ManagedFileContext {
|
|
156
179
|
const packageJson = readPackageJson(join(root, 'package.json'));
|
|
157
180
|
const repoName = packageJson?.name ?? 'monorepo';
|
|
181
|
+
const ciPushBranches = getCiPushBranches(packageJson?.json);
|
|
182
|
+
const stagingDeploy = getDeployTargetInfo(root, 'staging');
|
|
183
|
+
const productionDeploy = getDeployTargetInfo(root, 'production');
|
|
158
184
|
const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
|
|
159
185
|
? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
|
|
160
186
|
: `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
|
|
161
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
hasReleasePackages: listReleasePackages(root, packageJson).length > 0,
|
|
189
|
+
hasStagingDeployTargets: stagingDeploy.exists,
|
|
190
|
+
hasProductionDeployTargets: productionDeploy.exists,
|
|
191
|
+
stagingDeployProvider: stagingDeploy.provider,
|
|
192
|
+
productionDeployProvider: productionDeploy.provider,
|
|
193
|
+
ciPushBranches,
|
|
194
|
+
nodeModulesCacheKey,
|
|
195
|
+
repoName,
|
|
196
|
+
};
|
|
162
197
|
}
|
|
163
198
|
|
|
164
199
|
function renderTemplate(context: ManagedFileContext, template: string): string {
|
|
165
200
|
return template
|
|
166
201
|
.replaceAll('{{REPO_NAME}}', context.repoName)
|
|
202
|
+
.replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
|
|
167
203
|
.replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
|
|
168
204
|
}
|
|
169
205
|
|
|
206
|
+
function getDeployTargetInfo(root: string, configuration: string): DeployTargetInfo {
|
|
207
|
+
const output = execFileSync('nx', ['show', 'projects', '--withTarget', 'deploy', '--json'], {
|
|
208
|
+
cwd: root,
|
|
209
|
+
encoding: 'utf8',
|
|
210
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
211
|
+
});
|
|
212
|
+
const projects: unknown = JSON.parse(output);
|
|
213
|
+
if (!Array.isArray(projects) || !projects.every((project) => typeof project === 'string')) {
|
|
214
|
+
return { exists: false };
|
|
215
|
+
}
|
|
216
|
+
let exists = false;
|
|
217
|
+
let provider: DeployTargetInfo['provider'];
|
|
218
|
+
for (const project of projects) {
|
|
219
|
+
const target = nxDeployTarget(root, project, configuration);
|
|
220
|
+
if (!target.exists) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
exists = true;
|
|
224
|
+
provider ??= target.provider;
|
|
225
|
+
}
|
|
226
|
+
return { exists, provider };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function nxDeployTarget(root: string, project: string, configuration: string): DeployTargetInfo {
|
|
230
|
+
const output = execFileSync('nx', ['show', 'project', project, '--json'], {
|
|
231
|
+
cwd: root,
|
|
232
|
+
encoding: 'utf8',
|
|
233
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
234
|
+
});
|
|
235
|
+
const parsed: unknown = JSON.parse(output);
|
|
236
|
+
const projectJson = recordValue(parsed);
|
|
237
|
+
const targets = recordValue(projectJson?.targets);
|
|
238
|
+
const deploy = recordValue(targets?.deploy);
|
|
239
|
+
const configurations = recordValue(deploy?.configurations);
|
|
240
|
+
const config = recordValue(configurations?.[configuration]);
|
|
241
|
+
if (!config) {
|
|
242
|
+
return { exists: false };
|
|
243
|
+
}
|
|
244
|
+
const command = deployCommand(deploy, config);
|
|
245
|
+
return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function deployCommand(deploy: Record<string, unknown> | undefined, config: Record<string, unknown>): string {
|
|
249
|
+
const command = stringValue(config.command) ?? stringValue(recordValue(config.options)?.command);
|
|
250
|
+
return command ?? stringValue(recordValue(deploy?.options)?.command) ?? '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function stringValue(value: unknown): string | undefined {
|
|
254
|
+
return typeof value === 'string' ? value : undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function getCiPushBranches(packageJson: unknown): string[] {
|
|
258
|
+
const configured = readCiPushBranches(packageJson);
|
|
259
|
+
return configured.length > 0 ? configured : ['main'];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readCiPushBranches(packageJson: unknown): string[] {
|
|
263
|
+
const rootPackage = recordValue(packageJson);
|
|
264
|
+
const smoo = recordValue(rootPackage?.smoo);
|
|
265
|
+
const github = recordValue(smoo?.github);
|
|
266
|
+
const branches = Array.isArray(github?.pushBranches) ? github.pushBranches : [];
|
|
267
|
+
return branches.filter((branch): branch is string => typeof branch === 'string' && branch.length > 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
271
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
272
|
+
? (value as Record<string, unknown>)
|
|
273
|
+
: undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderYamlFlowList(values: string[]): string {
|
|
277
|
+
return JSON.stringify(values);
|
|
278
|
+
}
|
|
279
|
+
|
|
170
280
|
function writeManagedFile(path: string, content: string, executable: boolean): void {
|
|
171
281
|
mkdirSync(dirname(path), { recursive: true });
|
|
172
282
|
writeFileSync(path, content, { mode: executable ? 0o755 : 0o644 });
|
package/src/monorepo/nx-sync.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import { runStatus } from '../lib/run.js';
|
|
1
|
+
import { printCommandOutput, runResult, runStatus } from '../lib/run.js';
|
|
2
2
|
|
|
3
3
|
export async function fixNxSync(root: string, verbose = false): Promise<void> {
|
|
4
|
-
const
|
|
4
|
+
const result = verbose
|
|
5
|
+
? { exitCode: await runStatus('nx', ['sync'], root, false), stdout: '', stderr: '' }
|
|
6
|
+
: await runResult('nx', ['sync'], root);
|
|
7
|
+
const status = result.exitCode;
|
|
5
8
|
if (status !== 0) {
|
|
9
|
+
if (!verbose) {
|
|
10
|
+
printCommandOutput(result.stdout, result.stderr);
|
|
11
|
+
}
|
|
6
12
|
throw new Error(`nx sync failed with exit code ${status}`);
|
|
7
13
|
}
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
export async function validateNxSync(root: string, verbose = false): Promise<number> {
|
|
11
|
-
const
|
|
12
|
-
|
|
17
|
+
const result = verbose
|
|
18
|
+
? { exitCode: await runStatus('nx', ['sync:check'], root, false), stdout: '', stderr: '' }
|
|
19
|
+
: await runResult('nx', ['sync:check'], root);
|
|
20
|
+
if (result.exitCode === 0) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
if (!verbose) {
|
|
24
|
+
printCommandOutput(result.stdout, result.stderr);
|
|
25
|
+
}
|
|
26
|
+
return 1;
|
|
13
27
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test';
|
|
1
|
+
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
|
|
2
2
|
import { fixPackageHygiene, type PackageHygieneShell, validatePackageHygiene } from './package-hygiene.js';
|
|
3
3
|
|
|
4
4
|
describe('package hygiene', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
console.log = originalConsoleLog;
|
|
7
|
+
console.error = originalConsoleError;
|
|
8
|
+
});
|
|
9
|
+
|
|
5
10
|
it('runs sherif in autofix mode and selects highest dependency versions', async () => {
|
|
6
11
|
const shell = new RecordingShell();
|
|
7
12
|
|
|
@@ -17,8 +22,39 @@ describe('package hygiene', () => {
|
|
|
17
22
|
|
|
18
23
|
expect(shell.statuses).toEqual([{ command: 'sherif', args: ['--fail-on-warnings'], cwd: '/repo' }]);
|
|
19
24
|
});
|
|
25
|
+
|
|
26
|
+
it('prints captured sherif output when quiet validation fails', async () => {
|
|
27
|
+
const shell = new FailingResultShell('sherif stdout', 'sherif stderr');
|
|
28
|
+
const logs = captureConsoleLogs();
|
|
29
|
+
const errors = captureConsoleErrors();
|
|
30
|
+
|
|
31
|
+
const failures = await validatePackageHygiene('/repo', shell);
|
|
32
|
+
|
|
33
|
+
expect(failures).toBe(1);
|
|
34
|
+
expect(logs).toEqual(['sherif stdout']);
|
|
35
|
+
expect(errors).toEqual(['sherif stderr', 'sherif package hygiene validation failed']);
|
|
36
|
+
});
|
|
20
37
|
});
|
|
21
38
|
|
|
39
|
+
const originalConsoleLog = console.log;
|
|
40
|
+
const originalConsoleError = console.error;
|
|
41
|
+
|
|
42
|
+
function captureConsoleLogs(): string[] {
|
|
43
|
+
const logs: string[] = [];
|
|
44
|
+
spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
|
|
45
|
+
logs.push(args.join(' '));
|
|
46
|
+
});
|
|
47
|
+
return logs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function captureConsoleErrors(): string[] {
|
|
51
|
+
const errors: string[] = [];
|
|
52
|
+
spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
53
|
+
errors.push(args.join(' '));
|
|
54
|
+
});
|
|
55
|
+
return errors;
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
class RecordingShell implements PackageHygieneShell {
|
|
23
59
|
readonly runs: { command: string; args: string[]; cwd: string }[] = [];
|
|
24
60
|
readonly statuses: { command: string; args: string[]; cwd: string }[] = [];
|
|
@@ -32,3 +68,20 @@ class RecordingShell implements PackageHygieneShell {
|
|
|
32
68
|
return 0;
|
|
33
69
|
}
|
|
34
70
|
}
|
|
71
|
+
|
|
72
|
+
class FailingResultShell implements PackageHygieneShell {
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly stdout: string,
|
|
75
|
+
private readonly stderr: string,
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
async run(): Promise<void> {}
|
|
79
|
+
|
|
80
|
+
async runResult(): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
81
|
+
return { exitCode: 1, stdout: this.stdout, stderr: this.stderr };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async runStatus(): Promise<number> {
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
}
|