@smoothbricks/cli 0.11.13 → 0.11.15
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 +6 -1
- package/dist/monorepo/index.d.ts +2 -0
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +1 -1
- package/dist/monorepo/packed-package.d.ts +3 -3
- package/dist/monorepo/packed-package.d.ts.map +1 -1
- package/dist/monorepo/packed-package.js +11 -6
- package/dist/monorepo/packs/index.d.ts +8 -0
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +6 -5
- package/dist/monorepo/publish-workflow.js +5 -1
- package/dist/release/github-release.d.ts +16 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -2
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +3 -3
- package/managed/raw/tooling/direnv/devenv.smoo.nix +17 -13
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +35 -0
- package/managed/raw/tooling/direnv/secret-references.ts +79 -125
- package/managed/raw/tooling/direnv/setup-environment.ts +81 -26
- package/managed/templates/github/actions/setup-devenv/action.yml +19 -6
- package/package.json +2 -2
- package/src/cli.ts +10 -1
- package/src/monorepo/__tests__/publish-workflow.test.ts +3 -0
- package/src/monorepo/index.ts +6 -1
- package/src/monorepo/packed-package.ts +21 -6
- package/src/monorepo/packs/index.ts +14 -5
- package/src/monorepo/publish-workflow.ts +8 -1
- package/src/monorepo/secret-references.test.ts +52 -131
- package/src/release/github-release.ts +31 -2
- package/src/release/index.ts +3 -2
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider-neutral local secret resolution for smoo-managed repositories
|
|
3
|
-
* the developer-shell half of the declared Nx remote cache.
|
|
2
|
+
* Provider-neutral local secret resolution for smoo-managed repositories.
|
|
4
3
|
*
|
|
5
4
|
* Reads the root package.json `smoo.secrets` map and `smoo.remoteCache` block
|
|
6
5
|
* — the bootstrap twins of `PackageSecretCommand`, `PackageSmooConfig.secrets`
|
|
7
6
|
* and `PackageRemoteCacheConfig` in packages/cli/src/lib/json.ts. This file is
|
|
8
7
|
* a managed raw script: it runs from `tooling/direnv/setup-environment.ts`
|
|
9
|
-
* BEFORE `bun install`,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* anything this file would reject at runtime.
|
|
8
|
+
* BEFORE `bun install`, when no workspace package and no Typia transform exist
|
|
9
|
+
* yet, so both shapes are hand-validated here against exactly what json.ts
|
|
10
|
+
* declares. Keep them aligned; smoo's Typia validation fails the manifest at
|
|
11
|
+
* generation time for anything this file would reject at runtime.
|
|
14
12
|
*
|
|
15
13
|
* Routing per declared variable, in first-match order:
|
|
16
14
|
*
|
|
@@ -28,23 +26,18 @@
|
|
|
28
26
|
* Failures aggregate so a single direnv reload surfaces every problem.
|
|
29
27
|
* Error text names variables and exit codes only: secret values, provider
|
|
30
28
|
* stdout/stderr, and command arguments are never echoed.
|
|
29
|
+
* A resolved value still reaches the environment of the install its caller
|
|
30
|
+
* runs next, so `maskSecretValues` redacts those values out of any captured
|
|
31
|
+
* child output that caller replays.
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* a
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* runs in the developer's shell, not in the setup child, and the export is
|
|
41
|
-
* limited to those two variables so `smoo.secrets` stays process-local. It
|
|
42
|
-
* prints nothing when the repository declares no cache, when the shell already
|
|
43
|
-
* carries a server (a CI job env is never overwritten), or when the declared
|
|
44
|
-
* token has no value — Nx accepts only 200 or 404 from a cache server, so a
|
|
45
|
-
* server it cannot authenticate to fails every task instead of missing. Why
|
|
46
|
-
* the cache is off is said on stderr; the exit status stays 0, because a
|
|
47
|
-
* missing cache credential must never keep a shell from opening.
|
|
33
|
+
* The variable `smoo.remoteCache.tokenSecret` names is never resolved here,
|
|
34
|
+
* nor anywhere at shell entry. A remote cache is an optimization, and shell
|
|
35
|
+
* entry happens on every direnv reload and every `devenv shell -- <command>`:
|
|
36
|
+
* a provider command run there is a credential prompt on each of them. Nx
|
|
37
|
+
* reads NX_SELF_HOSTED_REMOTE_CACHE_SERVER and _ACCESS_TOKEN from the
|
|
38
|
+
* environment it runs in — CI injects them, a developer exports the token
|
|
39
|
+
* once in the terminal that wants the cache — and every nested shell inherits
|
|
40
|
+
* them. Absent, Nx keeps to its local cache.
|
|
48
41
|
*/
|
|
49
42
|
import { existsSync, readFileSync } from 'node:fs';
|
|
50
43
|
import { join } from 'node:path';
|
|
@@ -135,10 +128,6 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
|
|
|
135
128
|
return { command: [first, ...rest] };
|
|
136
129
|
}
|
|
137
130
|
|
|
138
|
-
/** The two variables Nx reads for its self-hosted HTTP cache. */
|
|
139
|
-
const REMOTE_CACHE_SERVER = 'NX_SELF_HOSTED_REMOTE_CACHE_SERVER';
|
|
140
|
-
const REMOTE_CACHE_ACCESS_TOKEN = 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN';
|
|
141
|
-
|
|
142
131
|
/**
|
|
143
132
|
* The declared remote cache as a local shell needs it. `internalServer` is
|
|
144
133
|
* deliberately absent: it is the address a managed runner reaches inside its
|
|
@@ -325,6 +314,69 @@ const runSecretCommand: SecretCommandRunner = async (argv) => {
|
|
|
325
314
|
return stdout;
|
|
326
315
|
};
|
|
327
316
|
|
|
317
|
+
/** '*', the byte a redacted secret leaves behind. */
|
|
318
|
+
const MASK_BYTE = 0x2a;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A copy of captured child output with every resolved secret value replaced
|
|
322
|
+
* by the same number of `*` bytes.
|
|
323
|
+
*
|
|
324
|
+
* setup-environment.ts puts the values this file resolves into the
|
|
325
|
+
* environment of the install it then runs, and replays that child's captured
|
|
326
|
+
* stdout/stderr verbatim when it fails. Whatever the child echoes — a prepare
|
|
327
|
+
* script printing its environment, a registry URL carrying an inline
|
|
328
|
+
* credential — would otherwise walk straight past the suppression the rest of
|
|
329
|
+
* this file maintains. Redaction is byte-exact and same-length, so the
|
|
330
|
+
* failure being reported stays legible and every offset in it still lines up.
|
|
331
|
+
* Output with nothing to redact is returned as it came in, so the path that
|
|
332
|
+
* has no secrets to hide copies nothing.
|
|
333
|
+
*/
|
|
334
|
+
export function maskSecretValues(output: Uint8Array, values: Iterable<string>): Uint8Array {
|
|
335
|
+
const encoder = new TextEncoder();
|
|
336
|
+
let masked: Uint8Array | undefined;
|
|
337
|
+
for (const value of values) {
|
|
338
|
+
const needle = encoder.encode(value);
|
|
339
|
+
if (needle.length === 0 || needle.length > output.length) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (let from = 0; from + needle.length <= output.length; ) {
|
|
343
|
+
const at = indexOfBytes(masked ?? output, needle, from);
|
|
344
|
+
if (at < 0) {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
// `new Uint8Array`, not `.slice()`: a captured child's output arrives as
|
|
348
|
+
// a node Buffer, whose `slice` is `subarray` — a view. Masking through
|
|
349
|
+
// one would rewrite the caller's own captured bytes.
|
|
350
|
+
masked ??= new Uint8Array(output);
|
|
351
|
+
masked.fill(MASK_BYTE, at, at + needle.length);
|
|
352
|
+
from = at + needle.length;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return masked ?? output;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** First occurrence of `needle` in `haystack` at or after `from`, or -1. */
|
|
359
|
+
function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number {
|
|
360
|
+
const first = needle[0];
|
|
361
|
+
if (first === undefined) {
|
|
362
|
+
return -1;
|
|
363
|
+
}
|
|
364
|
+
const last = haystack.length - needle.length;
|
|
365
|
+
for (let at = haystack.indexOf(first, from); at >= 0 && at <= last; at = haystack.indexOf(first, at + 1)) {
|
|
366
|
+
let matches = true;
|
|
367
|
+
for (let offset = 1; offset < needle.length; offset += 1) {
|
|
368
|
+
if (haystack[at + offset] !== needle[offset]) {
|
|
369
|
+
matches = false;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (matches) {
|
|
374
|
+
return at;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return -1;
|
|
378
|
+
}
|
|
379
|
+
|
|
328
380
|
/**
|
|
329
381
|
* The declared secrets an install needs, resolved before it runs. The cache
|
|
330
382
|
* token is deliberately not among them: a remote cache is an optimization, so
|
|
@@ -353,85 +405,6 @@ export async function resolveSecretEnvironment(
|
|
|
353
405
|
});
|
|
354
406
|
}
|
|
355
407
|
|
|
356
|
-
/**
|
|
357
|
-
* What the shell should do about the declared remote cache. Each case is a
|
|
358
|
-
* value, not an exception: three of the four are ordinary, and only one is
|
|
359
|
-
* worth saying out loud.
|
|
360
|
-
*/
|
|
361
|
-
export type RemoteCacheOutcome =
|
|
362
|
-
| { readonly kind: 'undeclared' }
|
|
363
|
-
| { readonly kind: 'inherited' }
|
|
364
|
-
| { readonly kind: 'unavailable'; readonly reason: string }
|
|
365
|
-
| { readonly kind: 'exported'; readonly env: Readonly<Record<string, string>> };
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* The remote cache variables this shell should export, or why it exports none.
|
|
369
|
-
* An inherited server always wins, so a CI job env — which carries the address
|
|
370
|
-
* its own runners reach — is never overwritten by the public one. The token is
|
|
371
|
-
* taken from the environment when it is there, and otherwise from the one
|
|
372
|
-
* `smoo.secrets` entry that declares it — resolving that single variable, so
|
|
373
|
-
* no other declared secret ever reaches the shell. A repository that declares
|
|
374
|
-
* the cache token as a provider secret therefore pays that one command twice
|
|
375
|
-
* per shell entry, once here and once in the setup child's own aggregate
|
|
376
|
-
* resolution; an ambient token pays nothing.
|
|
377
|
-
*/
|
|
378
|
-
export async function resolveRemoteCacheOutcome(options: SecretResolutionOptions): Promise<RemoteCacheOutcome> {
|
|
379
|
-
const env = options.env ?? process.env;
|
|
380
|
-
const packageJson = readPackageJson(options.root);
|
|
381
|
-
const spec = parseSmooRemoteCache(packageJson);
|
|
382
|
-
if (spec === null) {
|
|
383
|
-
return { kind: 'undeclared' };
|
|
384
|
-
}
|
|
385
|
-
if (isNonemptyEnvValue(env[REMOTE_CACHE_SERVER])) {
|
|
386
|
-
return { kind: 'inherited' };
|
|
387
|
-
}
|
|
388
|
-
const ambient = env[spec.tokenSecret];
|
|
389
|
-
if (isNonemptyEnvValue(ambient)) {
|
|
390
|
-
return {
|
|
391
|
-
kind: 'exported',
|
|
392
|
-
env: { [REMOTE_CACHE_SERVER]: spec.server, [REMOTE_CACHE_ACCESS_TOKEN]: ambient },
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
const declared = parseSmooSecrets(packageJson)[spec.tokenSecret];
|
|
396
|
-
if (declared === undefined) {
|
|
397
|
-
return {
|
|
398
|
-
kind: 'unavailable',
|
|
399
|
-
reason: `${spec.tokenSecret} is unset and no smoo.secrets entry declares it, so ${spec.server} would be asked for cache entries with no credential`,
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
// A cache token is not registry routing, so the cowshed `.npmrc` exclusion
|
|
403
|
-
// cannot apply to it; every other rule in the header does.
|
|
404
|
-
const outcome = await routeSecret(spec.tokenSecret, declared, {
|
|
405
|
-
env,
|
|
406
|
-
registryIntentEnvs: new Set(),
|
|
407
|
-
run: options.runCommand ?? runSecretCommand,
|
|
408
|
-
});
|
|
409
|
-
switch (outcome.kind) {
|
|
410
|
-
case 'resolved':
|
|
411
|
-
return {
|
|
412
|
-
kind: 'exported',
|
|
413
|
-
env: { [REMOTE_CACHE_SERVER]: spec.server, [REMOTE_CACHE_ACCESS_TOKEN]: outcome.value },
|
|
414
|
-
};
|
|
415
|
-
case 'failed':
|
|
416
|
-
return { kind: 'unavailable', reason: `${spec.tokenSecret}: ${outcome.guidance}` };
|
|
417
|
-
case 'env-wins':
|
|
418
|
-
// Only reachable if an ambient value appeared between the check above
|
|
419
|
-
// and this call; the export happens on the next shell entry.
|
|
420
|
-
return { kind: 'unavailable', reason: `${spec.tokenSecret} was set after it was read` };
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* POSIX `export` lines for a shell to `eval`. Single quotes are the only
|
|
426
|
-
* quoting a value cannot escape from, so each value is single-quoted with its
|
|
427
|
-
* own quotes spliced out — a token is opaque bytes, never assumed shell-safe.
|
|
428
|
-
*/
|
|
429
|
-
export function shellExportLines(env: Readonly<Record<string, string>>): string {
|
|
430
|
-
return Object.entries(env)
|
|
431
|
-
.map(([name, value]) => `export ${name}='${value.replaceAll("'", "'\\''")}'\n`)
|
|
432
|
-
.join('');
|
|
433
|
-
}
|
|
434
|
-
|
|
435
408
|
function readPackageJson(root: string): unknown {
|
|
436
409
|
const packageJsonPath = join(root, 'package.json');
|
|
437
410
|
let text: string;
|
|
@@ -452,27 +425,8 @@ function readPackageJson(root: string): unknown {
|
|
|
452
425
|
}
|
|
453
426
|
return parsed;
|
|
454
427
|
}
|
|
455
|
-
|
|
456
428
|
function readNpmrcText(root: string): string | null {
|
|
457
429
|
const npmrcPath = join(root, '.npmrc');
|
|
458
430
|
if (!existsSync(npmrcPath)) return null;
|
|
459
431
|
return readFileSync(npmrcPath, 'utf8');
|
|
460
432
|
}
|
|
461
|
-
|
|
462
|
-
// Program mode, run by the managed devenv shell as
|
|
463
|
-
// `eval "$(bun "$DEVENV_ROOT/secret-references.ts" "$PWD")"`. stdout is
|
|
464
|
-
// therefore shell text and carries nothing else; everything a human should
|
|
465
|
-
// read goes to stderr, and the status stays 0 so a shell always opens.
|
|
466
|
-
if (import.meta.main) {
|
|
467
|
-
const root = Bun.argv[2] ?? process.cwd();
|
|
468
|
-
try {
|
|
469
|
-
const outcome = await resolveRemoteCacheOutcome({ root });
|
|
470
|
-
if (outcome.kind === 'exported') {
|
|
471
|
-
process.stdout.write(shellExportLines(outcome.env));
|
|
472
|
-
} else if (outcome.kind === 'unavailable') {
|
|
473
|
-
console.error(`smoo: Nx remote cache off — ${outcome.reason}`);
|
|
474
|
-
}
|
|
475
|
-
} catch (error) {
|
|
476
|
-
console.error(`smoo: Nx remote cache off — ${error instanceof Error ? error.message : String(error)}`);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
@@ -4,7 +4,7 @@ import { mkdir, rmdir, stat } from 'node:fs/promises';
|
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { $ } from 'bun';
|
|
7
|
-
import { resolveSecretEnvironment } from './secret-references.ts';
|
|
7
|
+
import { maskSecretValues, resolveSecretEnvironment } from './secret-references.ts';
|
|
8
8
|
|
|
9
9
|
// DEVENV_ROOT is set by the devenv shell, which is how this script normally
|
|
10
10
|
// runs. CI jobs that install dependencies without building that shell (the
|
|
@@ -24,6 +24,14 @@ class CapturedCommandError extends Error {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Every value resolved out of smoo.secrets below. The install inherits them,
|
|
28
|
+
// so whatever a failing child echoed is redacted before its captured output
|
|
29
|
+
// is replayed. Declared above the first replay site (resolveProjectRoot's own
|
|
30
|
+
// failure) because module consts are not hoisted: a reference from there to a
|
|
31
|
+
// const declared further down would report a temporal-dead-zone error instead
|
|
32
|
+
// of the failure it was called to report.
|
|
33
|
+
const resolvedSecretValues: string[] = [];
|
|
34
|
+
|
|
27
35
|
async function resolveProjectRoot(): Promise<string> {
|
|
28
36
|
if (devenvRoot) {
|
|
29
37
|
return path.resolve(`${devenvRoot}/../..`);
|
|
@@ -66,19 +74,11 @@ const TYPESCRIPT_API_VERSION = '6.0.3';
|
|
|
66
74
|
process.chdir(projectRoot);
|
|
67
75
|
|
|
68
76
|
try {
|
|
69
|
-
// Provider-declared secrets (smoo.secrets) resolve before any install. The
|
|
70
|
-
// values land in THIS process environment only — bun install, the prepare
|
|
71
|
-
// scripts it runs, and every later child of this script inherit them (for
|
|
72
|
-
// example .npmrc `${VAR}` auth); the direnv shell itself does not, which is
|
|
73
|
-
// the point: this script must never act as a global shell export.
|
|
74
|
-
for (const [name, value] of Object.entries(await resolveSecretEnvironment({ root: projectRoot }))) {
|
|
75
|
-
process.env[name] = value;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
77
|
// Bootstrap only: install deps + wire local git hooks/config.
|
|
79
78
|
// Do not import workspace packages here — this script is what installs them,
|
|
80
79
|
// and package resolution/Typia transforms are not available yet.
|
|
81
80
|
if (process.env.CI) {
|
|
81
|
+
await resolveSecrets();
|
|
82
82
|
// Concurrent devenv activations share one node_modules, so the CI
|
|
83
83
|
// installs race exactly like local ones (EEXIST link failures under
|
|
84
84
|
// parallel shells). Serialize them under the same setup lock. Nothing
|
|
@@ -118,24 +118,60 @@ try {
|
|
|
118
118
|
process.exit(1);
|
|
119
119
|
}
|
|
120
120
|
} else {
|
|
121
|
-
|
|
121
|
+
// A local secret-resolution or install failure (a provider that is not
|
|
122
|
+
// signed in, an unpublished private package, a missing registry
|
|
123
|
+
// credential, a lockfile that needs `devenv update`) must not take the
|
|
124
|
+
// shell down with it: direnv drops the whole environment on a non-zero
|
|
125
|
+
// exit, and then bun, nx, op and every other tool needed to repair the
|
|
126
|
+
// install are gone too. Report it, finish what does not depend on the
|
|
127
|
+
// install, and load the shell. CI above stays strict.
|
|
128
|
+
const installError = await installLocalDependencies();
|
|
129
|
+
if (installError === undefined) {
|
|
130
|
+
// Pin unscoped typescript → API 6 for root and Bun's shared .bun hoist (Nx).
|
|
131
|
+
ensureTypeScriptApiPackage(projectRoot);
|
|
132
|
+
}
|
|
133
|
+
await applyWorkspaceGitConfig(projectRoot);
|
|
134
|
+
if (installError !== undefined) {
|
|
135
|
+
reportDegradedSetup(installError);
|
|
136
|
+
}
|
|
122
137
|
}
|
|
123
|
-
|
|
124
|
-
// Pin unscoped typescript → API 6 for root and Bun's shared .bun hoist (Nx).
|
|
125
|
-
ensureTypeScriptApiPackage(projectRoot);
|
|
126
|
-
|
|
127
|
-
await applyWorkspaceGitConfig(projectRoot);
|
|
128
138
|
} catch (error) {
|
|
129
139
|
reportSetupFailure(error);
|
|
130
140
|
}
|
|
131
141
|
|
|
132
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Provider-declared secrets (smoo.secrets) resolve before any install. The
|
|
144
|
+
* values land in THIS process environment only — bun install, the prepare
|
|
145
|
+
* scripts it runs, and every later child of this script inherit them (for
|
|
146
|
+
* example .npmrc `${VAR}` auth); the direnv shell itself does not, which is
|
|
147
|
+
* the point: this script must never act as a global shell export.
|
|
148
|
+
*/
|
|
149
|
+
async function resolveSecrets(): Promise<void> {
|
|
150
|
+
for (const [name, value] of Object.entries(await resolveSecretEnvironment({ root: projectRoot }))) {
|
|
151
|
+
process.env[name] = value;
|
|
152
|
+
resolvedSecretValues.push(value);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function installLocalDependencies(): Promise<unknown> {
|
|
157
|
+
try {
|
|
158
|
+
await resolveSecrets();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return error;
|
|
161
|
+
}
|
|
133
162
|
// bun install runs the root prepare script. Multiple concurrent direnv
|
|
134
163
|
// activations can otherwise race while mutating the same files under
|
|
135
|
-
// node_modules.
|
|
164
|
+
// node_modules. Nothing that exits the process may run inside the callback
|
|
165
|
+
// (see the CI branch), so the failure is returned, not thrown.
|
|
166
|
+
let installError: unknown;
|
|
136
167
|
await withSetupLock(async () => {
|
|
137
|
-
|
|
168
|
+
try {
|
|
169
|
+
await runSetupCommand('bun install --no-summary', $`bun install --no-summary`);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
installError = error;
|
|
172
|
+
}
|
|
138
173
|
});
|
|
174
|
+
return installError;
|
|
139
175
|
}
|
|
140
176
|
|
|
141
177
|
function ensureTypeScriptApiPackage(root: string): void {
|
|
@@ -374,25 +410,44 @@ function isFileExistsError(error: unknown): boolean {
|
|
|
374
410
|
}
|
|
375
411
|
|
|
376
412
|
function reportSetupFailure(error: unknown): never {
|
|
413
|
+
describeFailure('ERROR', error);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function reportDegradedSetup(error: unknown): void {
|
|
418
|
+
describeFailure('WARNING', error);
|
|
419
|
+
console.error(
|
|
420
|
+
'The shell is loaded WITHOUT installed dependencies so the tools to repair this stay available.\n' +
|
|
421
|
+
'Fix the cause above (missing registry credential, unpublished package, stale lockfile → `devenv update`),\n' +
|
|
422
|
+
'then run `bun install` or `direnv reload`.',
|
|
423
|
+
);
|
|
424
|
+
console.error('---');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function describeFailure(level: 'ERROR' | 'WARNING', error: unknown): void {
|
|
377
428
|
if (error instanceof CapturedCommandError) {
|
|
378
|
-
console.error(`---
|
|
429
|
+
console.error(`--- ${level}: setup-environment.ts failed while running: ${error.command}`);
|
|
379
430
|
console.error(`exit code: ${error.exitCode}`);
|
|
380
431
|
} else {
|
|
381
|
-
console.error(`---
|
|
432
|
+
console.error(`--- ${level}: setup-environment.ts failed: ${error}`);
|
|
382
433
|
}
|
|
383
434
|
replayCapturedOutput(error);
|
|
384
435
|
console.error('\n---');
|
|
385
|
-
process.exit(1);
|
|
386
436
|
}
|
|
387
437
|
|
|
388
438
|
function replayCapturedOutput(error: unknown): void {
|
|
389
439
|
if (!(error instanceof CapturedCommandError)) {
|
|
390
440
|
return;
|
|
391
441
|
}
|
|
392
|
-
|
|
393
|
-
|
|
442
|
+
// The install ran with every resolved secret in its environment, so its
|
|
443
|
+
// output is redacted before replay. Masking is same-length, which keeps the
|
|
444
|
+
// failure this replay exists to show intact.
|
|
445
|
+
const stdout = maskSecretValues(error.stdout, resolvedSecretValues);
|
|
446
|
+
const stderr = maskSecretValues(error.stderr, resolvedSecretValues);
|
|
447
|
+
if (stdout.length > 0) {
|
|
448
|
+
process.stdout.write(stdout);
|
|
394
449
|
}
|
|
395
|
-
if (
|
|
396
|
-
process.stderr.write(
|
|
450
|
+
if (stderr.length > 0) {
|
|
451
|
+
process.stderr.write(stderr);
|
|
397
452
|
}
|
|
398
453
|
}
|
|
@@ -109,6 +109,11 @@ runs:
|
|
|
109
109
|
# stable path (a symlink would not do: Nix canonicalises) and devenv is
|
|
110
110
|
# evaluated from there; every later step still runs in the checkout, which
|
|
111
111
|
# is the same tree. DEVENV_WORKDIR names the path the devenv steps use.
|
|
112
|
+
# The mount target is the instance's own /work, not the shared bind: only
|
|
113
|
+
# the path's spelling must be stable, and a bind onto the idmapped cache
|
|
114
|
+
# dataset failed inside the job (move_mount: ENOENT) where a container-fs
|
|
115
|
+
# target does not. If the mount is refused the job goes on from the
|
|
116
|
+
# checkout and pays the evaluation: this is an optimisation, never a gate.
|
|
112
117
|
- name: 📂 Locate devenv state on the shared bind
|
|
113
118
|
if: steps.runner-kind.outputs.host == 'true'
|
|
114
119
|
shell: bash
|
|
@@ -119,13 +124,19 @@ runs:
|
|
|
119
124
|
run: |
|
|
120
125
|
lane=$(printf '%s' "$DEVENV_STATE_LANE" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
|
|
121
126
|
root="/var/cache/ci/devenv/$DEVENV_STATE_REPO/${lane:-default}/$DEVENV_STATE_JOB"
|
|
122
|
-
mkdir -p "$root/.devenv" "$root/.direnv"
|
|
123
|
-
sudo mount --bind "$GITHUB_WORKSPACE" "$root/workspace"
|
|
127
|
+
mkdir -p "$root/.devenv" "$root/.direnv"
|
|
124
128
|
rm -rf tooling/direnv/.devenv tooling/direnv/.direnv
|
|
125
129
|
ln -s "$root/.devenv" tooling/direnv/.devenv
|
|
126
130
|
ln -s "$root/.direnv" tooling/direnv/.direnv
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
stable="/work/$DEVENV_STATE_REPO/${lane:-default}/$DEVENV_STATE_JOB"
|
|
132
|
+
if mkdir -p "$stable" && sudo mount --bind "$GITHUB_WORKSPACE" "$stable"; then
|
|
133
|
+
echo "DEVENV_WORKDIR=$stable" >> "$GITHUB_ENV"
|
|
134
|
+
echo "devenv state: $root (workspace bind-mounted at $stable)"
|
|
135
|
+
else
|
|
136
|
+
echo "DEVENV_WORKDIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
|
|
137
|
+
echo "::warning::stable devenv path unavailable (bind mount refused); evaluating from the checkout"
|
|
138
|
+
echo "devenv state: $root (workspace at $GITHUB_WORKSPACE)"
|
|
139
|
+
fi
|
|
129
140
|
|
|
130
141
|
- name: 📂 Devenv runs in the checkout
|
|
131
142
|
if: steps.runner-kind.outputs.host != 'true'
|
|
@@ -189,8 +200,8 @@ runs:
|
|
|
189
200
|
/nix
|
|
190
201
|
~/.cache/nix
|
|
191
202
|
# prettier-ignore
|
|
192
|
-
primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-
|
|
193
|
-
restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-
|
|
203
|
+
primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-v6-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
|
|
204
|
+
restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v6-
|
|
194
205
|
gc-max-store-size: 1G
|
|
195
206
|
|
|
196
207
|
- name: ⚡ Enable Cachix
|
|
@@ -225,4 +236,6 @@ runs:
|
|
|
225
236
|
- name: 🐚 Build devenv shell
|
|
226
237
|
shell: bash
|
|
227
238
|
working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
|
|
239
|
+
env:
|
|
240
|
+
SMOO_HOST_RUNNER: ${{ steps.runner-kind.outputs.host }}
|
|
228
241
|
run: ./github-actions-bootstrap.sh build-shell
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smoothbricks/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SmoothBricks monorepo automation CLI",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
],
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@arethetypeswrong/core": "^0.18.2",
|
|
67
|
-
"@smoothbricks/nx-plugin": "0.4.
|
|
67
|
+
"@smoothbricks/nx-plugin": "0.4.9",
|
|
68
68
|
"@smoothbricks/validation": "0.1.8",
|
|
69
69
|
"commander": "^14.0.3",
|
|
70
70
|
"make-synchronized": "^0.8.0",
|
package/src/cli.ts
CHANGED
|
@@ -89,15 +89,24 @@ function buildProgram(): Command {
|
|
|
89
89
|
.option('--fail-fast', 'stop after the first failing validation pack')
|
|
90
90
|
.option('--only-if-new-workspace-package', 'skip validation unless a new workspace package manifest is staged')
|
|
91
91
|
.option('--verbose', 'print validation progress and successful checks')
|
|
92
|
+
.option(
|
|
93
|
+
'--projects <names>',
|
|
94
|
+
'comma-separated Nx project names to build and pack-validate (a release selection); default: every project',
|
|
95
|
+
)
|
|
92
96
|
.action(
|
|
93
97
|
async (options: {
|
|
94
98
|
fix?: boolean;
|
|
95
99
|
failFast?: boolean;
|
|
96
100
|
onlyIfNewWorkspacePackage?: boolean;
|
|
97
101
|
verbose?: boolean;
|
|
102
|
+
projects?: string;
|
|
98
103
|
}) => {
|
|
99
104
|
const { validateMonorepo } = await import('./monorepo/index.js');
|
|
100
|
-
|
|
105
|
+
const projects = options.projects
|
|
106
|
+
?.split(',')
|
|
107
|
+
.map((name) => name.trim())
|
|
108
|
+
.filter((name) => name.length > 0);
|
|
109
|
+
await validateMonorepo(await findRepoRoot(), { ...options, projects });
|
|
101
110
|
},
|
|
102
111
|
);
|
|
103
112
|
monorepo.command('update').action(async () => {
|
|
@@ -38,6 +38,9 @@ const codebaseWorkflowOptions: PublishWorkflowDefinitionOptions = {
|
|
|
38
38
|
describe('publish workflow definition', () => {
|
|
39
39
|
it('renders the checked-in local publish workflow copy', async () => {
|
|
40
40
|
const rendered = renderPublishWorkflowYaml(codebaseWorkflowOptions);
|
|
41
|
+
// validate builds and pack-checks the release's candidates, not every project:
|
|
42
|
+
// unscoped it compiled binaries the release did not contain.
|
|
43
|
+
expect(rendered).toContain('smoo monorepo validate --projects "${{ steps.version.outputs.projects }}"');
|
|
41
44
|
const packageRoot = join(import.meta.dir, '..', '..', '..');
|
|
42
45
|
await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
|
|
43
46
|
rendered,
|
package/src/monorepo/index.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface ValidateOptions {
|
|
|
47
47
|
onlyIfNewWorkspacePackage?: boolean;
|
|
48
48
|
fix?: boolean;
|
|
49
49
|
verbose?: boolean;
|
|
50
|
+
/** Nx project names to build and pack-validate; see MonorepoContext.projects. */
|
|
51
|
+
projects?: readonly string[];
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export interface ValidateCommitMessageOptions {
|
|
@@ -85,7 +87,10 @@ export async function validateMonorepo(root: string, options: ValidateOptions =
|
|
|
85
87
|
if (options.onlyIfNewWorkspacePackage && !(await hasNewWorkspacePackage(root))) {
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
88
|
-
const result = await runValidatePacks(
|
|
90
|
+
const result = await runValidatePacks(
|
|
91
|
+
{ root, syncRuntime: false, verbose: options.verbose === true, projects: options.projects },
|
|
92
|
+
options,
|
|
93
|
+
);
|
|
89
94
|
if (result.failures > 0) {
|
|
90
95
|
const checkNoun = result.failedChecks === 1 ? 'check' : 'checks';
|
|
91
96
|
const problemNoun = result.failures === 1 ? 'problem' : 'problems';
|
|
@@ -21,25 +21,40 @@ export async function validatePackedPublishablePackages(root: string): Promise<n
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** The publishable packages, narrowed to `projects` (Nx names) when given. */
|
|
25
|
+
function selectedPublishablePackages(root: string, projects?: readonly string[]): PackageInfo[] {
|
|
26
|
+
const all = listPublishablePackages(root);
|
|
27
|
+
return projects?.length ? all.filter((pkg) => projects.includes(pkg.projectName)) : all;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function validatePackedPublishablePackagePublint(
|
|
31
|
+
root: string,
|
|
32
|
+
projects?: readonly string[],
|
|
33
|
+
): Promise<number> {
|
|
25
34
|
let failures = 0;
|
|
26
|
-
for (const pkg of
|
|
35
|
+
for (const pkg of selectedPublishablePackages(root, projects)) {
|
|
27
36
|
failures += await validatePackedPublishablePackageTool(root, pkg, validatePublint);
|
|
28
37
|
}
|
|
29
38
|
return failures;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
export async function validatePackedPublishablePackageManifest(
|
|
41
|
+
export async function validatePackedPublishablePackageManifest(
|
|
42
|
+
root: string,
|
|
43
|
+
projects?: readonly string[],
|
|
44
|
+
): Promise<number> {
|
|
33
45
|
let failures = 0;
|
|
34
|
-
for (const pkg of
|
|
46
|
+
for (const pkg of selectedPublishablePackages(root, projects)) {
|
|
35
47
|
failures += await validatePackedPublishablePackageTool(root, pkg, validatePackedManifest);
|
|
36
48
|
}
|
|
37
49
|
return failures;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
export async function validatePackedPublishablePackageTypes(
|
|
52
|
+
export async function validatePackedPublishablePackageTypes(
|
|
53
|
+
root: string,
|
|
54
|
+
projects?: readonly string[],
|
|
55
|
+
): Promise<number> {
|
|
41
56
|
let failures = 0;
|
|
42
|
-
for (const pkg of
|
|
57
|
+
for (const pkg of selectedPublishablePackages(root, projects)) {
|
|
43
58
|
failures += await validatePackedPublishablePackageTool(root, pkg, validateAttw);
|
|
44
59
|
}
|
|
45
60
|
return failures;
|
|
@@ -36,6 +36,14 @@ export interface MonorepoContext {
|
|
|
36
36
|
root: string;
|
|
37
37
|
syncRuntime: boolean;
|
|
38
38
|
verbose?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Nx project names the validation is about, when a caller knows: the
|
|
41
|
+
* release's candidates. The build phase and the packed-package packs scope
|
|
42
|
+
* to them; manifest-level packs still see the whole repository, because a
|
|
43
|
+
* package's hygiene does not depend on which packages ship today. Absent,
|
|
44
|
+
* everything is validated and everything is built.
|
|
45
|
+
*/
|
|
46
|
+
projects?: readonly string[];
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
export interface ValidatePackOptions {
|
|
@@ -174,19 +182,19 @@ const packs: MonorepoPack[] = [
|
|
|
174
182
|
{
|
|
175
183
|
name: 'packed-package-publint',
|
|
176
184
|
validatePostBuild(ctx) {
|
|
177
|
-
return validatePackedPublishablePackagePublint(ctx.root);
|
|
185
|
+
return validatePackedPublishablePackagePublint(ctx.root, ctx.projects);
|
|
178
186
|
},
|
|
179
187
|
},
|
|
180
188
|
{
|
|
181
189
|
name: 'packed-package-manifest',
|
|
182
190
|
validatePostBuild(ctx) {
|
|
183
|
-
return validatePackedPublishablePackageManifest(ctx.root);
|
|
191
|
+
return validatePackedPublishablePackageManifest(ctx.root, ctx.projects);
|
|
184
192
|
},
|
|
185
193
|
},
|
|
186
194
|
{
|
|
187
195
|
name: 'packed-package-types',
|
|
188
196
|
validatePostBuild(ctx) {
|
|
189
|
-
return validatePackedPublishablePackageTypes(ctx.root);
|
|
197
|
+
return validatePackedPublishablePackageTypes(ctx.root, ctx.projects);
|
|
190
198
|
},
|
|
191
199
|
},
|
|
192
200
|
{
|
|
@@ -369,9 +377,10 @@ async function runBuild(ctx: MonorepoContext, options: ValidatePackOptions = {})
|
|
|
369
377
|
if (options.verbose) {
|
|
370
378
|
printCheckHeading('build', true);
|
|
371
379
|
}
|
|
380
|
+
const args = ['run-many', '-t', 'build', ...(ctx.projects?.length ? ['-p', ctx.projects.join(',')] : [])];
|
|
372
381
|
const result = options.verbose
|
|
373
|
-
? { exitCode: await runStatus('nx',
|
|
374
|
-
: await runResult('nx',
|
|
382
|
+
? { exitCode: await runStatus('nx', args, ctx.root, false), stdout: '', stderr: '' }
|
|
383
|
+
: await runResult('nx', args, ctx.root);
|
|
375
384
|
const status = result.exitCode;
|
|
376
385
|
if (status !== 0) {
|
|
377
386
|
if (!options.verbose) {
|
|
@@ -614,7 +614,14 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
|
|
|
614
614
|
'failure()',
|
|
615
615
|
);
|
|
616
616
|
case PublishWorkflowStepKind.ValidateMonorepoConfig:
|
|
617
|
-
|
|
617
|
+
// Scoped to the release's candidates: the build phase and the packed-package
|
|
618
|
+
// checks cover what ships. Unscoped, validate compiled every project - on a
|
|
619
|
+
// release touching no Rust that was 3m30 of the cowshed CLI on the critical
|
|
620
|
+
// path, for a binary the release did not contain.
|
|
621
|
+
return conditionalRunStep(
|
|
622
|
+
step,
|
|
623
|
+
`smoo monorepo validate --projects "${githubExpression('steps.version.outputs.projects')}"`,
|
|
624
|
+
);
|
|
618
625
|
case PublishWorkflowStepKind.TagRelease:
|
|
619
626
|
return tagReleaseStepLines(step.name);
|
|
620
627
|
case PublishWorkflowStepKind.PublishRelease:
|