@smoothbricks/cli 0.10.7 → 0.10.8
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.d.ts.map +1 -1
- package/dist/cli.js +23 -1
- package/dist/github-ci/index.d.ts +43 -3
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +219 -34
- package/dist/monorepo/ci-workflow.js +16 -6
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +19 -1
- package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
- package/dist/monorepo/publish-workflow.js +2 -2
- package/dist/monorepo/tool-validation.d.ts.map +1 -1
- package/dist/monorepo/tool-validation.js +85 -5
- package/dist/playwright/index.d.ts +22 -0
- package/dist/playwright/index.d.ts.map +1 -0
- package/dist/playwright/index.js +44 -0
- package/dist/release/bootstrap-npm-packages.d.ts +3 -0
- package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
- package/dist/release/bootstrap-npm-packages.js +21 -0
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +17 -0
- package/dist/wrangler/cloudflare.d.ts +85 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -0
- package/dist/wrangler/cloudflare.js +235 -0
- package/dist/wrangler/deploy-environment.d.ts +48 -0
- package/dist/wrangler/deploy-environment.d.ts.map +1 -0
- package/dist/wrangler/deploy-environment.js +383 -0
- package/dist/wrangler/environment.d.ts +58 -0
- package/dist/wrangler/environment.d.ts.map +1 -0
- package/dist/wrangler/environment.js +297 -0
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
- package/package.json +8 -1
- package/src/cli.ts +25 -2
- package/src/github-ci/index.test.ts +171 -0
- package/src/github-ci/index.ts +272 -30
- package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
- package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +1 -1
- package/src/monorepo/ci-workflow.ts +16 -6
- package/src/monorepo/managed-files.test.ts +56 -1
- package/src/monorepo/managed-files.ts +20 -1
- package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
- package/src/monorepo/publish-workflow.ts +2 -2
- package/src/monorepo/tool-validation.test.ts +85 -0
- package/src/monorepo/tool-validation.ts +94 -5
- package/src/playwright/index.test.ts +90 -0
- package/src/playwright/index.ts +73 -0
- package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
- package/src/release/bootstrap-npm-packages.ts +34 -0
- package/src/release/index.ts +18 -0
- package/src/wrangler/cloudflare.ts +289 -0
- package/src/wrangler/deploy-environment.test.ts +354 -0
- package/src/wrangler/deploy-environment.ts +445 -0
- package/src/wrangler/environment.test.ts +173 -0
- package/src/wrangler/environment.ts +366 -0
|
@@ -486,7 +486,7 @@ function deployProductionStep(step: PublishWorkflowStep, options: PublishWorkflo
|
|
|
486
486
|
" ${{ steps.version.outputs.mode != 'none' && inputs.deploy_environment == 'production' && inputs.dry_run !=",
|
|
487
487
|
" 'true' }}",
|
|
488
488
|
...deployEnvLines(options),
|
|
489
|
-
' run: smoo github-ci nx-deploy --
|
|
489
|
+
' run: smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
|
|
490
490
|
];
|
|
491
491
|
}
|
|
492
492
|
|
|
@@ -940,7 +940,7 @@ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions)
|
|
|
940
940
|
" ${{ needs.linux-release-candidate.outputs.mode != 'none' && inputs.deploy_environment == 'production' &&",
|
|
941
941
|
" inputs.dry_run != 'true' }}",
|
|
942
942
|
...deployEnvLines(options),
|
|
943
|
-
' run: smoo github-ci nx-deploy --
|
|
943
|
+
' run: smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
|
|
944
944
|
);
|
|
945
945
|
}
|
|
946
946
|
lines.push(
|
|
@@ -3,10 +3,12 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import {
|
|
6
|
+
applyDevenvPackageDefaults,
|
|
6
7
|
applyRootDevDependencyDefaults,
|
|
7
8
|
applyToolConfigDefaults,
|
|
8
9
|
applyToolingPackageDefaults,
|
|
9
10
|
readToolContext,
|
|
11
|
+
validateDevenvPackages,
|
|
10
12
|
validateToolConfig,
|
|
11
13
|
} from './tool-validation.js';
|
|
12
14
|
|
|
@@ -89,6 +91,88 @@ describe('tool configuration validation', () => {
|
|
|
89
91
|
expect(devenv).toContain('nodejs_latest');
|
|
90
92
|
expect(devenv).toContain('coreutils');
|
|
91
93
|
expect(devenv).toContain('git-format-staged');
|
|
94
|
+
expect(devenv).toContain('go # Builds ttsc source plugins');
|
|
95
|
+
expect(devenv).not.toContain('sccache');
|
|
96
|
+
} finally {
|
|
97
|
+
await rm(root, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('requires Go for managed TypeScript source-plugin builds without forcing Rust tools', async () => {
|
|
102
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
|
|
103
|
+
try {
|
|
104
|
+
const devenvPath = join(root, 'tooling/direnv/devenv.nix');
|
|
105
|
+
await mkdir(dirname(devenvPath), { recursive: true });
|
|
106
|
+
await writeFile(
|
|
107
|
+
devenvPath,
|
|
108
|
+
`{
|
|
109
|
+
pkgs,
|
|
110
|
+
...
|
|
111
|
+
}: {
|
|
112
|
+
packages = with pkgs; [
|
|
113
|
+
nodejs_latest
|
|
114
|
+
bun
|
|
115
|
+
git
|
|
116
|
+
git-format-staged
|
|
117
|
+
jq
|
|
118
|
+
alejandra
|
|
119
|
+
coreutils
|
|
120
|
+
gnutar
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
`,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(validateDevenvPackages(root)).toBe(1);
|
|
127
|
+
applyDevenvPackageDefaults(root);
|
|
128
|
+
expect(validateDevenvPackages(root)).toBe(0);
|
|
129
|
+
const devenv = await readFile(devenvPath, 'utf8');
|
|
130
|
+
expect(devenv).toContain('go # Builds ttsc source plugins');
|
|
131
|
+
expect(devenv).not.toContain('sccache');
|
|
132
|
+
} finally {
|
|
133
|
+
await rm(root, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('requires Rust compiler helpers only when the workspace contains Cargo manifests', async () => {
|
|
138
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
|
|
139
|
+
try {
|
|
140
|
+
const crateRoot = join(root, 'packages/native');
|
|
141
|
+
await mkdir(crateRoot, { recursive: true });
|
|
142
|
+
await writeFile(join(crateRoot, 'Cargo.toml'), '[package]\nname = "native"\nversion = "0.0.0"\n');
|
|
143
|
+
const devenvPath = join(root, 'tooling/direnv/devenv.nix');
|
|
144
|
+
await mkdir(dirname(devenvPath), { recursive: true });
|
|
145
|
+
await writeFile(
|
|
146
|
+
devenvPath,
|
|
147
|
+
`{
|
|
148
|
+
pkgs,
|
|
149
|
+
lib,
|
|
150
|
+
...
|
|
151
|
+
}: {
|
|
152
|
+
packages = with pkgs; [
|
|
153
|
+
nodejs_latest
|
|
154
|
+
bun
|
|
155
|
+
git
|
|
156
|
+
git-format-staged
|
|
157
|
+
jq
|
|
158
|
+
alejandra
|
|
159
|
+
coreutils
|
|
160
|
+
gnutar
|
|
161
|
+
go
|
|
162
|
+
stdenv.cc.cc.lib
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(validateDevenvPackages(root)).toBe(2);
|
|
169
|
+
applyDevenvPackageDefaults(root);
|
|
170
|
+
expect(validateDevenvPackages(root)).toBe(0);
|
|
171
|
+
const devenv = await readFile(devenvPath, 'utf8');
|
|
172
|
+
expect(devenv).toContain('sccache # Rust compiler wrapper and cache');
|
|
173
|
+
expect(devenv).toContain('++ lib.optionals pkgs.stdenv.isLinux [');
|
|
174
|
+
expect(devenv).toContain('pkgs.stdenv.cc');
|
|
175
|
+
expect(devenv).toContain('stdenv.cc.cc.lib');
|
|
92
176
|
} finally {
|
|
93
177
|
await rm(root, { recursive: true, force: true });
|
|
94
178
|
}
|
|
@@ -141,6 +225,7 @@ describe('tool configuration validation', () => {
|
|
|
141
225
|
alejandra
|
|
142
226
|
coreutils
|
|
143
227
|
gnutar
|
|
228
|
+
go
|
|
144
229
|
];
|
|
145
230
|
}
|
|
146
231
|
`,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import typia from 'typia';
|
|
4
4
|
import { cliPackageVersion, isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
|
|
@@ -67,12 +67,24 @@ const rootDevDependencies: RequiredDependency[] = [
|
|
|
67
67
|
|
|
68
68
|
const cliPackageName = '@smoothbricks/cli';
|
|
69
69
|
|
|
70
|
-
const requiredDevenvPackages = ['bun', 'git', 'git-format-staged', 'jq', 'alejandra', 'coreutils', 'gnutar'];
|
|
70
|
+
const requiredDevenvPackages = ['bun', 'git', 'git-format-staged', 'jq', 'alejandra', 'coreutils', 'gnutar', 'go'];
|
|
71
71
|
// Any explicit nodejs provider is fine — the pinned major is the repo's choice
|
|
72
72
|
// (Lambda parity, org convergence, …). Whether the pins in package.json agree
|
|
73
73
|
// with the runtime is validated against the live PATH (validateRootRuntimeVersions),
|
|
74
74
|
// never against a version template here.
|
|
75
75
|
const nodePackagePattern = /(^|\s)nodejs(_\d+|_latest)?(\s|#|$)/m;
|
|
76
|
+
const requiredRustDevenvPackages = ['sccache'];
|
|
77
|
+
const linuxCompilerPackage = 'pkgs.stdenv.cc';
|
|
78
|
+
const ignoredNativeManifestDirectories = new Set([
|
|
79
|
+
'.devenv',
|
|
80
|
+
'.direnv',
|
|
81
|
+
'.git',
|
|
82
|
+
'.nx',
|
|
83
|
+
'dist',
|
|
84
|
+
'node_modules',
|
|
85
|
+
'target',
|
|
86
|
+
'vendor',
|
|
87
|
+
]);
|
|
76
88
|
|
|
77
89
|
const obsoleteTtscPatchedDependencyKey = 'ttsc@0.19.3';
|
|
78
90
|
|
|
@@ -201,7 +213,9 @@ export function applyDevenvPackageDefaults(root: string): void {
|
|
|
201
213
|
changed = next !== content || changed;
|
|
202
214
|
content = next;
|
|
203
215
|
}
|
|
204
|
-
|
|
216
|
+
const compilesRust = workspaceCompilesRust(root);
|
|
217
|
+
const requiredPackages = [...requiredDevenvPackages, ...(compilesRust ? requiredRustDevenvPackages : [])];
|
|
218
|
+
for (const name of requiredPackages) {
|
|
205
219
|
if (hasNixPackage(content, name)) {
|
|
206
220
|
continue;
|
|
207
221
|
}
|
|
@@ -209,6 +223,11 @@ export function applyDevenvPackageDefaults(root: string): void {
|
|
|
209
223
|
changed = next !== content || changed;
|
|
210
224
|
content = next;
|
|
211
225
|
}
|
|
226
|
+
if (compilesRust && !hasLinuxCompilerPackage(content)) {
|
|
227
|
+
const next = addLinuxCompilerPackage(content);
|
|
228
|
+
changed = next !== content || changed;
|
|
229
|
+
content = next;
|
|
230
|
+
}
|
|
212
231
|
if (changed) {
|
|
213
232
|
writeFileSync(path, content);
|
|
214
233
|
console.log('updated tooling/direnv/devenv.nix packages');
|
|
@@ -316,12 +335,20 @@ export function validateDevenvPackages(root: string): number {
|
|
|
316
335
|
);
|
|
317
336
|
failures++;
|
|
318
337
|
}
|
|
319
|
-
|
|
338
|
+
const compilesRust = workspaceCompilesRust(root);
|
|
339
|
+
const requiredPackages = [...requiredDevenvPackages, ...(compilesRust ? requiredRustDevenvPackages : [])];
|
|
340
|
+
for (const name of requiredPackages) {
|
|
320
341
|
if (!hasNixPackage(content, name)) {
|
|
321
342
|
console.error(`tooling/direnv/devenv.nix packages must include ${name}`);
|
|
322
343
|
failures++;
|
|
323
344
|
}
|
|
324
345
|
}
|
|
346
|
+
if (compilesRust && !hasLinuxCompilerPackage(content)) {
|
|
347
|
+
console.error(
|
|
348
|
+
'tooling/direnv/devenv.nix packages must include pkgs.stdenv.cc in a Linux-only package block for Rust builds',
|
|
349
|
+
);
|
|
350
|
+
failures++;
|
|
351
|
+
}
|
|
325
352
|
return failures;
|
|
326
353
|
}
|
|
327
354
|
|
|
@@ -330,7 +357,9 @@ function addNixPackage(content: string, name: string, comment: string): string {
|
|
|
330
357
|
if (hasNixPackage(content, name)) {
|
|
331
358
|
return content;
|
|
332
359
|
}
|
|
333
|
-
const
|
|
360
|
+
const directPackagesStart = content.indexOf(' packages = with pkgs; [');
|
|
361
|
+
const groupedPackagesStart = content.indexOf(' (with pkgs; [');
|
|
362
|
+
const packagesStart = directPackagesStart === -1 ? groupedPackagesStart : directPackagesStart;
|
|
334
363
|
if (packagesStart === -1) {
|
|
335
364
|
return content;
|
|
336
365
|
}
|
|
@@ -342,6 +371,60 @@ function hasNixPackage(content: string, name: string): boolean {
|
|
|
342
371
|
return new RegExp(`(^|\\s)${escapeRegex(name)}(\\s|#|$)`, 'm').test(content);
|
|
343
372
|
}
|
|
344
373
|
|
|
374
|
+
function workspaceCompilesRust(root: string): boolean {
|
|
375
|
+
return containsCargoManifest(root);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function containsCargoManifest(directory: string): boolean {
|
|
379
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
380
|
+
if (entry.isFile() && entry.name === 'Cargo.toml') {
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
if (
|
|
384
|
+
entry.isDirectory() &&
|
|
385
|
+
entry.name.startsWith('.') === false &&
|
|
386
|
+
ignoredNativeManifestDirectories.has(entry.name) === false &&
|
|
387
|
+
containsCargoManifest(join(directory, entry.name))
|
|
388
|
+
) {
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function hasLinuxCompilerPackage(content: string): boolean {
|
|
396
|
+
const linuxPackages = /\+\+\s+lib\.optionals\s+pkgs\.stdenv\.isLinux\s+\[([\s\S]*?)\]/g;
|
|
397
|
+
return [...content.matchAll(linuxPackages)].some((match) => hasNixPackage(match[1] ?? '', linuxCompilerPackage));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function addLinuxCompilerPackage(content: string): string {
|
|
401
|
+
if (hasLinuxCompilerPackage(content)) {
|
|
402
|
+
return content;
|
|
403
|
+
}
|
|
404
|
+
const directStart = content.indexOf(' packages = with pkgs; [');
|
|
405
|
+
if (directStart !== -1) {
|
|
406
|
+
const listEnd = content.indexOf('\n ];', directStart);
|
|
407
|
+
if (listEnd === -1) {
|
|
408
|
+
return content;
|
|
409
|
+
}
|
|
410
|
+
const listBody = content.slice(directStart + ' packages = with pkgs; ['.length, listEnd);
|
|
411
|
+
const replacement = ` packages =\n (with pkgs; [${listBody}\n ])\n ++ lib.optionals pkgs.stdenv.isLinux [\n ${linuxCompilerPackage}\n ];`;
|
|
412
|
+
return `${content.slice(0, directStart)}${replacement}${content.slice(listEnd + '\n ];'.length)}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const packagesStart = content.indexOf(' packages =');
|
|
416
|
+
const nextOption = content.indexOf('\n\n ', packagesStart);
|
|
417
|
+
if (packagesStart === -1 || nextOption === -1) {
|
|
418
|
+
return content;
|
|
419
|
+
}
|
|
420
|
+
const terminator = content.lastIndexOf(';', nextOption);
|
|
421
|
+
if (terminator < packagesStart) {
|
|
422
|
+
return content;
|
|
423
|
+
}
|
|
424
|
+
const linuxPackages = `\n ++ lib.optionals pkgs.stdenv.isLinux [\n ${linuxCompilerPackage}\n ]`;
|
|
425
|
+
return `${content.slice(0, terminator)}${linuxPackages}${content.slice(terminator)}`;
|
|
426
|
+
}
|
|
427
|
+
|
|
345
428
|
function nixPackageComment(name: string): string {
|
|
346
429
|
if (name === 'coreutils') {
|
|
347
430
|
return '# Provides fmt for commit message wrapping';
|
|
@@ -352,6 +435,12 @@ function nixPackageComment(name: string): string {
|
|
|
352
435
|
if (name === 'git') {
|
|
353
436
|
return '# Git hooks and repository inspection';
|
|
354
437
|
}
|
|
438
|
+
if (name === 'go') {
|
|
439
|
+
return '# Builds ttsc source plugins';
|
|
440
|
+
}
|
|
441
|
+
if (name === 'sccache') {
|
|
442
|
+
return '# Rust compiler wrapper and cache';
|
|
443
|
+
}
|
|
355
444
|
return '';
|
|
356
445
|
}
|
|
357
446
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { type ChromiumSetupDependencies, ensureChromium } from './index.js';
|
|
3
|
+
|
|
4
|
+
interface RunCall {
|
|
5
|
+
readonly command: string;
|
|
6
|
+
readonly args: string[];
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly env?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function setupDependencies(
|
|
12
|
+
env: ChromiumSetupDependencies['env'],
|
|
13
|
+
existingPaths: readonly string[],
|
|
14
|
+
calls: RunCall[],
|
|
15
|
+
): ChromiumSetupDependencies {
|
|
16
|
+
const existing = new Set(existingPaths);
|
|
17
|
+
return {
|
|
18
|
+
env,
|
|
19
|
+
exists: (path) => existing.has(path),
|
|
20
|
+
run: async (command, args, cwd, runEnv) => {
|
|
21
|
+
calls.push({ command, args, cwd, ...(runEnv ? { env: runEnv } : {}) });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ensureChromium', () => {
|
|
27
|
+
it('uses preinstalled Chrome without invoking Playwright install on an ephemeral GitHub runner', async () => {
|
|
28
|
+
const calls: RunCall[] = [];
|
|
29
|
+
const executablePath = '/fixture/google-chrome';
|
|
30
|
+
|
|
31
|
+
const result = await ensureChromium(
|
|
32
|
+
'/workspace/package',
|
|
33
|
+
setupDependencies(
|
|
34
|
+
{ GITHUB_ACTIONS: 'true', PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: executablePath },
|
|
35
|
+
[executablePath],
|
|
36
|
+
calls,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual({ mode: 'system', executablePath });
|
|
41
|
+
expect(calls).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('installs Chromium into the persistent host-runner cache', async () => {
|
|
45
|
+
const calls: RunCall[] = [];
|
|
46
|
+
|
|
47
|
+
const result = await ensureChromium(
|
|
48
|
+
'/workspace/package',
|
|
49
|
+
setupDependencies({ GITHUB_ACTIONS: 'true' }, ['/var/cache/ci'], calls),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ mode: 'persistent-cache', browserCachePath: '/var/cache/ci/ms-playwright' });
|
|
53
|
+
expect(calls).toEqual([
|
|
54
|
+
{
|
|
55
|
+
command: 'playwright',
|
|
56
|
+
args: ['install', 'chromium', '--only-shell'],
|
|
57
|
+
cwd: '/workspace/package',
|
|
58
|
+
env: { PLAYWRIGHT_BROWSERS_PATH: '/var/cache/ci/ms-playwright' },
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('fails rather than downloading when an ephemeral GitHub runner has no system Chrome', async () => {
|
|
64
|
+
const calls: RunCall[] = [];
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
ensureChromium('/workspace/package', setupDependencies({ GITHUB_ACTIONS: 'true' }, [], calls)),
|
|
68
|
+
).rejects.toThrow('Refusing to download');
|
|
69
|
+
expect(calls).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('honors an explicit developer cache outside GitHub Actions', async () => {
|
|
73
|
+
const calls: RunCall[] = [];
|
|
74
|
+
|
|
75
|
+
const result = await ensureChromium(
|
|
76
|
+
'/workspace/package',
|
|
77
|
+
setupDependencies({ PLAYWRIGHT_BROWSERS_PATH: '/tmp/browser-cache' }, [], calls),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({ mode: 'developer-cache', browserCachePath: '/tmp/browser-cache' });
|
|
81
|
+
expect(calls).toEqual([
|
|
82
|
+
{
|
|
83
|
+
command: 'playwright',
|
|
84
|
+
args: ['install', 'chromium', '--only-shell'],
|
|
85
|
+
cwd: '/workspace/package',
|
|
86
|
+
env: { PLAYWRIGHT_BROWSERS_PATH: '/tmp/browser-cache' },
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { run } from '../lib/run.js';
|
|
3
|
+
|
|
4
|
+
const HOST_CACHE_ROOT = '/var/cache/ci';
|
|
5
|
+
const HOST_BROWSER_CACHE = `${HOST_CACHE_ROOT}/ms-playwright`;
|
|
6
|
+
const SYSTEM_CHROME_PATHS = [
|
|
7
|
+
'/usr/bin/google-chrome',
|
|
8
|
+
'/usr/bin/chromium-browser',
|
|
9
|
+
'/usr/bin/chromium',
|
|
10
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export interface ChromiumSetupDependencies {
|
|
14
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
15
|
+
readonly exists: (path: string) => boolean;
|
|
16
|
+
readonly run: (command: string, args: string[], cwd: string, env?: Record<string, string>) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ChromiumSetupResult =
|
|
20
|
+
| { readonly mode: 'persistent-cache'; readonly browserCachePath: string }
|
|
21
|
+
| { readonly mode: 'system'; readonly executablePath: string }
|
|
22
|
+
| { readonly mode: 'developer-cache'; readonly browserCachePath?: string };
|
|
23
|
+
|
|
24
|
+
const defaultDependencies: ChromiumSetupDependencies = {
|
|
25
|
+
env: process.env,
|
|
26
|
+
exists: existsSync,
|
|
27
|
+
run,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure Chromium is available without polluting ephemeral GitHub runners.
|
|
32
|
+
* Persistent host runners and developer machines may install into their
|
|
33
|
+
* respective caches; ephemeral GitHub runners must use their image's browser.
|
|
34
|
+
*/
|
|
35
|
+
export async function ensureChromium(
|
|
36
|
+
cwd = process.cwd(),
|
|
37
|
+
dependencies: ChromiumSetupDependencies = defaultDependencies,
|
|
38
|
+
): Promise<ChromiumSetupResult> {
|
|
39
|
+
const { env, exists, run: runCommand } = dependencies;
|
|
40
|
+
const githubActions = env.GITHUB_ACTIONS === 'true';
|
|
41
|
+
|
|
42
|
+
if (githubActions && exists(HOST_CACHE_ROOT)) {
|
|
43
|
+
await runCommand('playwright', ['install', 'chromium', '--only-shell'], cwd, {
|
|
44
|
+
PLAYWRIGHT_BROWSERS_PATH: HOST_BROWSER_CACHE,
|
|
45
|
+
});
|
|
46
|
+
console.log(`Chromium ready in persistent cache: ${HOST_BROWSER_CACHE}`);
|
|
47
|
+
return { mode: 'persistent-cache', browserCachePath: HOST_BROWSER_CACHE };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (githubActions) {
|
|
51
|
+
const candidates = [env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, ...SYSTEM_CHROME_PATHS].filter(
|
|
52
|
+
(path): path is string => typeof path === 'string' && path.length > 0,
|
|
53
|
+
);
|
|
54
|
+
const executablePath = candidates.find(exists);
|
|
55
|
+
if (!executablePath) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`GitHub-hosted runner has no preinstalled Chromium. Refusing to download; searched: ${candidates.join(', ')}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
console.log(`Using preinstalled Chromium: ${executablePath}`);
|
|
61
|
+
return { mode: 'system', executablePath };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const browserCachePath = env.PLAYWRIGHT_BROWSERS_PATH;
|
|
65
|
+
await runCommand(
|
|
66
|
+
'playwright',
|
|
67
|
+
['install', 'chromium', '--only-shell'],
|
|
68
|
+
cwd,
|
|
69
|
+
browserCachePath ? { PLAYWRIGHT_BROWSERS_PATH: browserCachePath } : undefined,
|
|
70
|
+
);
|
|
71
|
+
console.log(browserCachePath ? `Chromium ready in configured cache: ${browserCachePath}` : 'Chromium ready.');
|
|
72
|
+
return browserCachePath ? { mode: 'developer-cache', browserCachePath } : { mode: 'developer-cache' };
|
|
73
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
bootstrapNpmPackages,
|
|
5
5
|
NPM_BOOTSTRAP_DIST_TAG,
|
|
6
6
|
NPM_BOOTSTRAP_VERSION,
|
|
7
|
+
NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS,
|
|
7
8
|
} from '../bootstrap-npm-packages.js';
|
|
8
9
|
import type { ReleasePackageInfo } from '../core.js';
|
|
9
10
|
|
|
@@ -70,6 +71,45 @@ describe('bootstrap npm packages', () => {
|
|
|
70
71
|
expect(shell.prompts).toEqual([]);
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
it('uploads every placeholder before polling all pending packages with backoff', async () => {
|
|
75
|
+
const shell = new RecordingBootstrapShell({
|
|
76
|
+
packages: [stable, missing],
|
|
77
|
+
existing: [],
|
|
78
|
+
visibleAfterChecks: { [stable.name]: 2, [missing.name]: 3 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await bootstrapNpmPackages(shell, { dryRun: false, skipLogin: true, packages: [], otp: '111222' });
|
|
82
|
+
|
|
83
|
+
expect(shell.events).toEqual([
|
|
84
|
+
`publish:${stable.name}`,
|
|
85
|
+
`publish:${missing.name}`,
|
|
86
|
+
`view:${stable.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
87
|
+
`view:${missing.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
88
|
+
'wait:1000',
|
|
89
|
+
`view:${stable.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
90
|
+
`view:${missing.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
91
|
+
'wait:2000',
|
|
92
|
+
`view:${missing.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('times out after bounded backoff without suggesting another bootstrap', async () => {
|
|
97
|
+
const shell = new RecordingBootstrapShell({
|
|
98
|
+
packages: [missing],
|
|
99
|
+
existing: [],
|
|
100
|
+
visibleAfterChecks: { [missing.name]: Number.POSITIVE_INFINITY },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
bootstrapNpmPackages(shell, { dryRun: false, skipLogin: true, packages: [], otp: '111222' }),
|
|
105
|
+
).rejects.toThrow('Retry smoo release trust-publisher later; do not bootstrap again.');
|
|
106
|
+
|
|
107
|
+
const waited = shell.events
|
|
108
|
+
.filter((event) => event.startsWith('wait:'))
|
|
109
|
+
.reduce((total, event) => total + Number(event.slice('wait:'.length)), 0);
|
|
110
|
+
expect(waited).toBe(NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS);
|
|
111
|
+
});
|
|
112
|
+
|
|
73
113
|
it('rejects unknown package selections before npm login', async () => {
|
|
74
114
|
const shell = new RecordingBootstrapShell({ packages: [stable], existing: [] });
|
|
75
115
|
|
|
@@ -82,6 +122,7 @@ describe('bootstrap npm packages', () => {
|
|
|
82
122
|
});
|
|
83
123
|
|
|
84
124
|
class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackageInfo> {
|
|
125
|
+
readonly events: string[] = [];
|
|
85
126
|
readonly logs: string[] = [];
|
|
86
127
|
readonly published: Array<{ name: string; otp: string }> = [];
|
|
87
128
|
readonly prompts: string[] = [];
|
|
@@ -89,11 +130,19 @@ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackag
|
|
|
89
130
|
private readonly packages: ReleasePackageInfo[];
|
|
90
131
|
private readonly existing: Set<string>;
|
|
91
132
|
private readonly otps: string[];
|
|
92
|
-
|
|
93
|
-
|
|
133
|
+
private readonly visibilityChecks = new Map<string, number>();
|
|
134
|
+
private readonly visibleAfterChecks: Readonly<Record<string, number>>;
|
|
135
|
+
|
|
136
|
+
constructor(options: {
|
|
137
|
+
packages: ReleasePackageInfo[];
|
|
138
|
+
existing: string[];
|
|
139
|
+
otps?: string[];
|
|
140
|
+
visibleAfterChecks?: Record<string, number>;
|
|
141
|
+
}) {
|
|
94
142
|
this.packages = options.packages;
|
|
95
143
|
this.existing = new Set(options.existing);
|
|
96
144
|
this.otps = [...(options.otps ?? [])];
|
|
145
|
+
this.visibleAfterChecks = options.visibleAfterChecks ?? {};
|
|
97
146
|
}
|
|
98
147
|
|
|
99
148
|
listReleasePackages(): ReleasePackageInfo[] {
|
|
@@ -104,11 +153,19 @@ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackag
|
|
|
104
153
|
return this.existing.has(name);
|
|
105
154
|
}
|
|
106
155
|
|
|
156
|
+
async packageVersionExists(name: string, version: string): Promise<boolean> {
|
|
157
|
+
this.events.push(`view:${name}:${version}`);
|
|
158
|
+
const checks = (this.visibilityChecks.get(name) ?? 0) + 1;
|
|
159
|
+
this.visibilityChecks.set(name, checks);
|
|
160
|
+
return checks >= (this.visibleAfterChecks[name] ?? 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
107
163
|
async login(): Promise<void> {
|
|
108
164
|
this.logins += 1;
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
async publishPlaceholder(pkg: ReleasePackageInfo, env?: Record<string, string>): Promise<void> {
|
|
168
|
+
this.events.push(`publish:${pkg.name}`);
|
|
112
169
|
this.published.push({ name: pkg.name, otp: env?.NPM_CONFIG_OTP ?? '' });
|
|
113
170
|
}
|
|
114
171
|
|
|
@@ -121,6 +178,10 @@ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackag
|
|
|
121
178
|
return otp;
|
|
122
179
|
}
|
|
123
180
|
|
|
181
|
+
async wait(milliseconds: number): Promise<void> {
|
|
182
|
+
this.events.push(`wait:${milliseconds}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
log(message: string): void {
|
|
125
186
|
this.logs.push(message);
|
|
126
187
|
}
|
|
@@ -2,6 +2,9 @@ import type { ReleasePackageInfo } from './core.js';
|
|
|
2
2
|
|
|
3
3
|
export const NPM_BOOTSTRAP_VERSION = '0.0.0-bootstrap.0';
|
|
4
4
|
export const NPM_BOOTSTRAP_DIST_TAG = 'bootstrap';
|
|
5
|
+
export const NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS = 120_000;
|
|
6
|
+
|
|
7
|
+
const NPM_BOOTSTRAP_POLL_DELAYS_MS = [0, 1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 30_000, 30_000] as const;
|
|
5
8
|
|
|
6
9
|
export interface BootstrapNpmPackagesOptions {
|
|
7
10
|
dryRun: boolean;
|
|
@@ -13,9 +16,11 @@ export interface BootstrapNpmPackagesOptions {
|
|
|
13
16
|
export interface BootstrapNpmPackagesShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
|
|
14
17
|
listReleasePackages(): Package[];
|
|
15
18
|
packageExists(name: string): Promise<boolean>;
|
|
19
|
+
packageVersionExists(name: string, version: string): Promise<boolean>;
|
|
16
20
|
login(): Promise<void>;
|
|
17
21
|
publishPlaceholder(pkg: Package, env?: Record<string, string>): Promise<void>;
|
|
18
22
|
promptOtp(packageName: string): Promise<string>;
|
|
23
|
+
wait(milliseconds: number): Promise<void>;
|
|
19
24
|
log(message: string): void;
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -54,15 +59,44 @@ export async function bootstrapNpmPackages<Package extends ReleasePackageInfo>(
|
|
|
54
59
|
if (!options.skipLogin) {
|
|
55
60
|
await shell.login();
|
|
56
61
|
}
|
|
62
|
+
// Upload every placeholder before waiting for npm registry propagation. Keeping the
|
|
63
|
+
// phases separate prevents one slow package from blocking the remaining uploads.
|
|
57
64
|
for (const pkg of missing) {
|
|
58
65
|
shell.log(`${pkg.name}: publishing npm placeholder.`);
|
|
59
66
|
const otp = options.otp ?? (await shell.promptOtp(pkg.name));
|
|
60
67
|
await shell.publishPlaceholder(pkg, { NPM_CONFIG_OTP: otp });
|
|
61
68
|
}
|
|
69
|
+
|
|
70
|
+
await waitForPublishedPackages(shell, missing);
|
|
62
71
|
shell.log('Bootstrap complete. Run smoo release trust-publisher before the first CI publish.');
|
|
63
72
|
return missing;
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
async function waitForPublishedPackages<Package extends ReleasePackageInfo>(
|
|
76
|
+
shell: BootstrapNpmPackagesShell<Package>,
|
|
77
|
+
packages: readonly Package[],
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
let pending = [...packages];
|
|
80
|
+
for (const delayMs of NPM_BOOTSTRAP_POLL_DELAYS_MS) {
|
|
81
|
+
if (delayMs > 0) {
|
|
82
|
+
await shell.wait(delayMs);
|
|
83
|
+
}
|
|
84
|
+
const visible = await Promise.all(
|
|
85
|
+
pending.map((pkg) => shell.packageVersionExists(pkg.name, NPM_BOOTSTRAP_VERSION)),
|
|
86
|
+
);
|
|
87
|
+
pending = pending.filter((_, index) => !visible[index]);
|
|
88
|
+
if (pending.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Bootstrap uploads succeeded, but npm did not expose these packages after ${NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS / 1_000} seconds: ${pending
|
|
95
|
+
.map((pkg) => pkg.name)
|
|
96
|
+
.join(', ')}. Retry smoo release trust-publisher later; do not bootstrap again.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
function selectedReleasePackages<Package extends ReleasePackageInfo>(
|
|
67
101
|
packages: Package[],
|
|
68
102
|
selections: string[],
|
package/src/release/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { Writable } from 'node:stream';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
8
|
import { $ } from 'bun';
|
|
8
9
|
import typia from 'typia';
|
|
9
10
|
import { githubCiApplyOutputs, githubCiNxRunMany } from '../github-ci/index.js';
|
|
@@ -251,9 +252,11 @@ export async function releaseTrustPublisher(root: string, options: ReleaseTrustP
|
|
|
251
252
|
{
|
|
252
253
|
listReleasePackages: () => listReleasePackages(root),
|
|
253
254
|
packageExists: (name) => npmPackageExists(root, name),
|
|
255
|
+
packageVersionExists: (name, version) => npmPublishedVersionExists(root, name, version),
|
|
254
256
|
login: () => runLatestNpm(root, ['login', '--auth-type=web']),
|
|
255
257
|
publishPlaceholder: (pkg, env) => publishPlaceholderPackage(root, pkg, env),
|
|
256
258
|
promptOtp: (packageName) => promptForNpmOtp(packageName),
|
|
259
|
+
wait: delay,
|
|
257
260
|
log: (message) => console.log(message),
|
|
258
261
|
},
|
|
259
262
|
bootstrapOptions,
|
|
@@ -460,9 +463,11 @@ export async function releaseBootstrapNpmPackages(
|
|
|
460
463
|
{
|
|
461
464
|
listReleasePackages: () => listReleasePackages(root),
|
|
462
465
|
packageExists: (name) => npmPackageExists(root, name),
|
|
466
|
+
packageVersionExists: (name, version) => npmPublishedVersionExists(root, name, version),
|
|
463
467
|
login: () => runLatestNpm(root, ['login', '--auth-type=web']),
|
|
464
468
|
publishPlaceholder: (pkg, env) => publishPlaceholderPackage(root, pkg, env),
|
|
465
469
|
promptOtp: (packageName) => promptForNpmOtp(packageName),
|
|
470
|
+
wait: delay,
|
|
466
471
|
log: (message) => console.log(message),
|
|
467
472
|
},
|
|
468
473
|
{
|
|
@@ -1506,6 +1511,19 @@ async function npmPackageExists(root: string, name: string): Promise<boolean> {
|
|
|
1506
1511
|
return result.exitCode === 0;
|
|
1507
1512
|
}
|
|
1508
1513
|
|
|
1514
|
+
async function npmPublishedVersionExists(root: string, name: string, version: string): Promise<boolean> {
|
|
1515
|
+
const spec = `${name}@${version}`;
|
|
1516
|
+
const args = ['view', spec, 'version', '--json'];
|
|
1517
|
+
const result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
|
|
1518
|
+
if (result.exitCode === 0) {
|
|
1519
|
+
return true;
|
|
1520
|
+
}
|
|
1521
|
+
if (/\bE404\b|404 Not Found/i.test(`${result.stdout}\n${result.stderr}`)) {
|
|
1522
|
+
return false;
|
|
1523
|
+
}
|
|
1524
|
+
throw new Error(npmCommandFailedMessage(args, result.exitCode, result.stdout, result.stderr));
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1509
1527
|
async function assertCleanGitTree(root: string): Promise<void> {
|
|
1510
1528
|
const result = await $`git status --porcelain --untracked-files=no`.cwd(root).quiet().nothrow();
|
|
1511
1529
|
if (result.exitCode !== 0) {
|