@smoothbricks/cli 0.11.11 → 0.11.13
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/README.md +104 -20
- package/dist/lib/json.d.ts +51 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +111 -67
- package/dist/monorepo/cargo-policy.d.ts +25 -1
- package/dist/monorepo/cargo-policy.d.ts.map +1 -1
- package/dist/monorepo/cargo-policy.js +396 -4
- package/dist/monorepo/ci-workflow.d.ts +27 -2
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +151 -11
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +5 -1
- package/dist/monorepo/managed-files.d.ts +3 -1
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +3 -0
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +13 -0
- package/dist/monorepo/publish-workflow.d.ts +8 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +15 -5
- package/managed/raw/tooling/direnv/devenv.smoo.nix +28 -2
- package/managed/raw/tooling/direnv/secret-references.ts +248 -51
- package/managed/templates/github/actions/save-nix-devenv/action.yml +2 -2
- package/managed/templates/github/actions/setup-devenv/action.yml +43 -5
- package/package.json +2 -2
- package/src/lib/json.ts +52 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +183 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +29 -0
- package/src/monorepo/cargo-policy.test.ts +206 -3
- package/src/monorepo/cargo-policy.ts +341 -2
- package/src/monorepo/ci-workflow.ts +180 -11
- package/src/monorepo/index.ts +5 -1
- package/src/monorepo/managed-files.ts +6 -0
- package/src/monorepo/packs/index.test.ts +32 -0
- package/src/monorepo/packs/index.ts +13 -0
- package/src/monorepo/publish-workflow.ts +24 -4
- package/src/monorepo/secret-references.test.ts +175 -2
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Provider-neutral local secret resolution for smoo-managed repositories
|
|
2
|
+
* Provider-neutral local secret resolution for smoo-managed repositories, and
|
|
3
|
+
* the developer-shell half of the declared Nx remote cache.
|
|
3
4
|
*
|
|
4
|
-
* Reads the root package.json `smoo.secrets` map
|
|
5
|
-
* `PackageSecretCommand
|
|
6
|
-
* packages/cli/src/lib/json.ts. This file is
|
|
7
|
-
* from `tooling/direnv/setup-environment.ts`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Reads the root package.json `smoo.secrets` map and `smoo.remoteCache` block
|
|
6
|
+
* — the bootstrap twins of `PackageSecretCommand`, `PackageSmooConfig.secrets`
|
|
7
|
+
* and `PackageRemoteCacheConfig` in packages/cli/src/lib/json.ts. This file is
|
|
8
|
+
* a managed raw script: it runs from `tooling/direnv/setup-environment.ts`
|
|
9
|
+
* BEFORE `bun install`, and from the managed devenv `enterShell` before that,
|
|
10
|
+
* when no workspace package and no Typia transform exist yet, so both shapes
|
|
11
|
+
* are hand-validated here against exactly what json.ts declares. Keep them
|
|
10
12
|
* aligned; smoo's Typia validation fails the manifest at generation time for
|
|
11
13
|
* anything this file would reject at runtime.
|
|
12
14
|
*
|
|
@@ -26,6 +28,23 @@
|
|
|
26
28
|
* Failures aggregate so a single direnv reload surfaces every problem.
|
|
27
29
|
* Error text names variables and exit codes only: secret values, provider
|
|
28
30
|
* stdout/stderr, and command arguments are never echoed.
|
|
31
|
+
*
|
|
32
|
+
* One declared variable is exempt from blocking an install: the one
|
|
33
|
+
* `smoo.remoteCache.tokenSecret` names. A remote cache is an optimization, so
|
|
34
|
+
* an unreachable secret provider must not keep dependencies from installing or
|
|
35
|
+
* a shell from opening; that variable is resolved by the cache export below,
|
|
36
|
+
* where failure costs a stderr line.
|
|
37
|
+
*
|
|
38
|
+
* Run as a program (`bun secret-references.ts [root]`), the script prints the
|
|
39
|
+
* remote cache's `export` lines for the shell to `eval` and nothing else: Nx
|
|
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.
|
|
29
48
|
*/
|
|
30
49
|
import { existsSync, readFileSync } from 'node:fs';
|
|
31
50
|
import { join } from 'node:path';
|
|
@@ -69,7 +88,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
69
88
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
70
89
|
}
|
|
71
90
|
|
|
72
|
-
function isNonemptyEnvValue(value: string | undefined):
|
|
91
|
+
function isNonemptyEnvValue(value: string | undefined): value is string {
|
|
73
92
|
return value !== undefined && value.length > 0;
|
|
74
93
|
}
|
|
75
94
|
|
|
@@ -80,9 +99,9 @@ function isNonemptyEnvValue(value: string | undefined): boolean {
|
|
|
80
99
|
*/
|
|
81
100
|
export function parseSmooSecrets(packageJson: unknown): Readonly<Record<string, SecretSpec>> {
|
|
82
101
|
if (!isRecord(packageJson)) return {};
|
|
83
|
-
const smoo = packageJson
|
|
102
|
+
const smoo = packageJson.smoo;
|
|
84
103
|
if (!isRecord(smoo)) return {};
|
|
85
|
-
const secrets = smoo
|
|
104
|
+
const secrets = smoo.secrets;
|
|
86
105
|
if (secrets === undefined) return {};
|
|
87
106
|
if (!isRecord(secrets)) {
|
|
88
107
|
throw new Error('smoo.secrets must map environment variable names to { command: [string, ...] }');
|
|
@@ -103,7 +122,7 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
|
|
|
103
122
|
if (!isRecord(spec)) {
|
|
104
123
|
throw new Error(`smoo.secrets.${name}: each entry must be an object with a command array`);
|
|
105
124
|
}
|
|
106
|
-
const raw = spec
|
|
125
|
+
const raw = spec.command;
|
|
107
126
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
108
127
|
throw new Error(`smoo.secrets.${name}.command: must be an array of at least one string`);
|
|
109
128
|
}
|
|
@@ -116,6 +135,53 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
|
|
|
116
135
|
return { command: [first, ...rest] };
|
|
117
136
|
}
|
|
118
137
|
|
|
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
|
+
/**
|
|
143
|
+
* The declared remote cache as a local shell needs it. `internalServer` is
|
|
144
|
+
* deliberately absent: it is the address a managed runner reaches inside its
|
|
145
|
+
* own network, and a shell that loads this file is by definition outside it,
|
|
146
|
+
* so a developer machine always takes the public `server`.
|
|
147
|
+
*/
|
|
148
|
+
export interface RemoteCacheSpec {
|
|
149
|
+
readonly server: string;
|
|
150
|
+
/** Variable holding the cache token: an ambient value, or a `smoo.secrets` entry resolved here. */
|
|
151
|
+
readonly tokenSecret: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validates the `smoo.remoteCache` block out of an already-parsed
|
|
156
|
+
* package.json. Absent `smoo` or `smoo.remoteCache` is no cache; a malformed
|
|
157
|
+
* declaration throws naming the field.
|
|
158
|
+
*/
|
|
159
|
+
export function parseSmooRemoteCache(packageJson: unknown): RemoteCacheSpec | null {
|
|
160
|
+
if (!isRecord(packageJson)) return null;
|
|
161
|
+
const smoo = packageJson.smoo;
|
|
162
|
+
if (!isRecord(smoo)) return null;
|
|
163
|
+
const remoteCache = smoo.remoteCache;
|
|
164
|
+
if (remoteCache === undefined) return null;
|
|
165
|
+
if (!isRecord(remoteCache)) {
|
|
166
|
+
throw new Error('smoo.remoteCache must be an object with { server, tokenSecret }');
|
|
167
|
+
}
|
|
168
|
+
const server = remoteCache.server;
|
|
169
|
+
const tokenSecret = remoteCache.tokenSecret;
|
|
170
|
+
// A trailing slash is refused rather than trimmed: Nx appends
|
|
171
|
+
// `/v1/cache/<hash>`, so the doubled slash is a route that answers 404
|
|
172
|
+
// forever, and silently repairing the manifest here would leave managed CI
|
|
173
|
+
// — which reads the same field — disagreeing with this shell.
|
|
174
|
+
if (typeof server !== 'string' || server.length === 0 || server.endsWith('/')) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'smoo.remoteCache.server must be an origin with no trailing slash, e.g. https://nx-cache.example.net',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (typeof tokenSecret !== 'string' || !ENV_NAME.test(tokenSecret)) {
|
|
180
|
+
throw new Error('smoo.remoteCache.tokenSecret must name the environment variable holding the cache token');
|
|
181
|
+
}
|
|
182
|
+
return { server, tokenSecret };
|
|
183
|
+
}
|
|
184
|
+
|
|
119
185
|
/** Every `${VAR}` referenced from `.npmrc` text; an absent file contributes nothing. */
|
|
120
186
|
export function registryAuthEnvNames(npmrcText: string | null): ReadonlySet<string> {
|
|
121
187
|
const names = new Set<string>();
|
|
@@ -131,47 +197,64 @@ type SecretOutcome =
|
|
|
131
197
|
| { readonly name: string; readonly kind: 'resolved'; readonly value: string }
|
|
132
198
|
| { readonly name: string; readonly kind: 'failed'; readonly guidance: string };
|
|
133
199
|
|
|
200
|
+
/**
|
|
201
|
+
* One variable's routing, the four rules in the header applied in order. It is
|
|
202
|
+
* a value rather than a throw so each caller presents a failure in its own
|
|
203
|
+
* terms: an install refuses, the remote cache turns itself off.
|
|
204
|
+
*/
|
|
205
|
+
async function routeSecret(
|
|
206
|
+
name: string,
|
|
207
|
+
spec: SecretSpec,
|
|
208
|
+
context: {
|
|
209
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
210
|
+
readonly registryIntentEnvs: ReadonlySet<string>;
|
|
211
|
+
readonly run: SecretCommandRunner;
|
|
212
|
+
},
|
|
213
|
+
): Promise<SecretOutcome> {
|
|
214
|
+
const { env } = context;
|
|
215
|
+
if (isNonemptyEnvValue(env[name])) {
|
|
216
|
+
return { name, kind: 'env-wins' };
|
|
217
|
+
}
|
|
218
|
+
if (isNonemptyEnvValue(env.CI)) {
|
|
219
|
+
return {
|
|
220
|
+
name,
|
|
221
|
+
kind: 'failed',
|
|
222
|
+
guidance:
|
|
223
|
+
'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (isNonemptyEnvValue(env.COWSHED_WORKSPACE_TOKEN) && context.registryIntentEnvs.has(name)) {
|
|
227
|
+
return {
|
|
228
|
+
name,
|
|
229
|
+
kind: 'failed',
|
|
230
|
+
guidance:
|
|
231
|
+
'referenced from .npmrc and absent in this cowshed workspace — enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve .npmrc registry variables via local provider commands',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const value = (await context.run(spec.command)).replace(/\r?\n$/, '');
|
|
236
|
+
if (value.length === 0) {
|
|
237
|
+
return {
|
|
238
|
+
name,
|
|
239
|
+
kind: 'failed',
|
|
240
|
+
guidance:
|
|
241
|
+
'provider command produced no output — run it locally to see why (its arguments and output are never logged here)',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return { name, kind: 'resolved', value };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return { name, kind: 'failed', guidance: describeCommandFailure(error) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
134
250
|
export async function resolveSecrets(request: SecretResolutionRequest): Promise<Readonly<Record<string, string>>> {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
251
|
+
const context = {
|
|
252
|
+
env: request.env,
|
|
253
|
+
registryIntentEnvs: request.registryIntentEnvs,
|
|
254
|
+
run: request.runCommand ?? runSecretCommand,
|
|
255
|
+
};
|
|
139
256
|
const outcomes = await Promise.all(
|
|
140
|
-
Object.entries(request.secrets).map(async ([name, spec])
|
|
141
|
-
if (isNonemptyEnvValue(env[name])) {
|
|
142
|
-
return { name, kind: 'env-wins' };
|
|
143
|
-
}
|
|
144
|
-
if (ci) {
|
|
145
|
-
return {
|
|
146
|
-
name,
|
|
147
|
-
kind: 'failed',
|
|
148
|
-
guidance:
|
|
149
|
-
'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
if (cowshed && request.registryIntentEnvs.has(name)) {
|
|
153
|
-
return {
|
|
154
|
-
name,
|
|
155
|
-
kind: 'failed',
|
|
156
|
-
guidance:
|
|
157
|
-
'referenced from .npmrc and absent in this cowshed workspace — enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve .npmrc registry variables via local provider commands',
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
const value = (await run(spec.command)).replace(/\r?\n$/, '');
|
|
162
|
-
if (value.length === 0) {
|
|
163
|
-
return {
|
|
164
|
-
name,
|
|
165
|
-
kind: 'failed',
|
|
166
|
-
guidance:
|
|
167
|
-
'provider command produced no output — run it locally to see why (its arguments and output are never logged here)',
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
return { name, kind: 'resolved', value };
|
|
171
|
-
} catch (error) {
|
|
172
|
-
return { name, kind: 'failed', guidance: describeCommandFailure(error) };
|
|
173
|
-
}
|
|
174
|
-
}),
|
|
257
|
+
Object.entries(request.secrets).map(async ([name, spec]) => routeSecret(name, spec, context)),
|
|
175
258
|
);
|
|
176
259
|
const failures = outcomes.filter(
|
|
177
260
|
(outcome): outcome is Extract<SecretOutcome, { kind: 'failed' }> => outcome.kind === 'failed',
|
|
@@ -242,17 +325,113 @@ const runSecretCommand: SecretCommandRunner = async (argv) => {
|
|
|
242
325
|
return stdout;
|
|
243
326
|
};
|
|
244
327
|
|
|
328
|
+
/**
|
|
329
|
+
* The declared secrets an install needs, resolved before it runs. The cache
|
|
330
|
+
* token is deliberately not among them: a remote cache is an optimization, so
|
|
331
|
+
* its credential is resolved by the shell's cache export — where an
|
|
332
|
+
* unreachable secret provider costs a stderr line — while every secret an
|
|
333
|
+
* install actually depends on still refuses loudly here. Which variable that
|
|
334
|
+
* is comes from `smoo.remoteCache.tokenSecret` rather than a second flag, so
|
|
335
|
+
* the two declarations cannot disagree.
|
|
336
|
+
*/
|
|
245
337
|
export async function resolveSecretEnvironment(
|
|
246
338
|
options: SecretResolutionOptions,
|
|
247
339
|
): Promise<Readonly<Record<string, string>>> {
|
|
340
|
+
const packageJson = readPackageJson(options.root);
|
|
341
|
+
const cacheToken = parseSmooRemoteCache(packageJson)?.tokenSecret;
|
|
342
|
+
const required: Record<string, SecretSpec> = {};
|
|
343
|
+
for (const [name, spec] of Object.entries(parseSmooSecrets(packageJson))) {
|
|
344
|
+
if (name !== cacheToken) {
|
|
345
|
+
required[name] = spec;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
248
348
|
return resolveSecrets({
|
|
249
|
-
secrets:
|
|
349
|
+
secrets: required,
|
|
250
350
|
registryIntentEnvs: registryAuthEnvNames(readNpmrcText(options.root)),
|
|
251
351
|
env: options.env ?? process.env,
|
|
252
352
|
runCommand: options.runCommand,
|
|
253
353
|
});
|
|
254
354
|
}
|
|
255
355
|
|
|
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
|
+
|
|
256
435
|
function readPackageJson(root: string): unknown {
|
|
257
436
|
const packageJsonPath = join(root, 'package.json');
|
|
258
437
|
let text: string;
|
|
@@ -279,3 +458,21 @@ function readNpmrcText(root: string): string | null {
|
|
|
279
458
|
if (!existsSync(npmrcPath)) return null;
|
|
280
459
|
return readFileSync(npmrcPath, 'utf8');
|
|
281
460
|
}
|
|
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name: Save Nix/devenv caches
|
|
2
2
|
description: >
|
|
3
|
-
Save the devenv/direnv eval-cache segment after the job, on every runner where setup missed and build-shell
|
|
4
|
-
nix-eval-cache.db. The Nix store cache saves itself in setup-devenv's post phase (ephemeral runners only).
|
|
3
|
+
Save the devenv/direnv eval-cache segment after the job, on every ephemeral runner where setup missed and build-shell
|
|
4
|
+
produced nix-eval-cache.db. The Nix store cache saves itself in setup-devenv's post phase (ephemeral runners only).
|
|
5
5
|
|
|
6
6
|
inputs:
|
|
7
7
|
devenv-cache-hit:
|
|
@@ -8,7 +8,7 @@ description: >
|
|
|
8
8
|
outputs:
|
|
9
9
|
devenv-cache-hit:
|
|
10
10
|
description: Whether the devenv/direnv cache restored from the exact primary key.
|
|
11
|
-
value: ${{ steps.devenv-cache.outputs.cache-hit || 'false' }}
|
|
11
|
+
value: ${{ steps.runner-kind.outputs.host == 'true' || steps.devenv-cache.outputs.cache-hit || 'false' }}
|
|
12
12
|
host-runner:
|
|
13
13
|
description: Whether this job is on a host-nix runner with shared store/caches.
|
|
14
14
|
value: ${{ steps.runner-kind.outputs.host }}
|
|
@@ -95,14 +95,52 @@ runs:
|
|
|
95
95
|
} >> "$GITHUB_ENV"
|
|
96
96
|
echo "nx cache : $NX_CACHE_ROOT"
|
|
97
97
|
|
|
98
|
+
# Host runners keep devenv's state on the shared bind, next to the Nx cache:
|
|
99
|
+
# .devenv (eval SQLite, gc roots, profile) and .direnv survive the job, so
|
|
100
|
+
# the next job on this host evaluates nothing and downloads nothing. The
|
|
101
|
+
# Actions cache segment below is the ephemeral runners' substitute for this.
|
|
102
|
+
# Scoped per job as well as lane: two jobs of one run are parallel, and one
|
|
103
|
+
# eval-cache.db has one writer.
|
|
104
|
+
#
|
|
105
|
+
# The eval cache keys on canonical absolute paths, and act's host executor
|
|
106
|
+
# checks out under <cache>/<random 8 chars>/hostexecutor - a new path every
|
|
107
|
+
# job by design (act/runner/run_context.go: MustRandName), so a restored
|
|
108
|
+
# cache could never match. The workspace is therefore bind-mounted at a
|
|
109
|
+
# stable path (a symlink would not do: Nix canonicalises) and devenv is
|
|
110
|
+
# evaluated from there; every later step still runs in the checkout, which
|
|
111
|
+
# is the same tree. DEVENV_WORKDIR names the path the devenv steps use.
|
|
112
|
+
- name: 📂 Locate devenv state on the shared bind
|
|
113
|
+
if: steps.runner-kind.outputs.host == 'true'
|
|
114
|
+
shell: bash
|
|
115
|
+
env:
|
|
116
|
+
DEVENV_STATE_REPO: ${{ github.repository }}
|
|
117
|
+
DEVENV_STATE_LANE: ${{ github.workflow }}
|
|
118
|
+
DEVENV_STATE_JOB: ${{ github.job }}
|
|
119
|
+
run: |
|
|
120
|
+
lane=$(printf '%s' "$DEVENV_STATE_LANE" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
|
|
121
|
+
root="/var/cache/ci/devenv/$DEVENV_STATE_REPO/${lane:-default}/$DEVENV_STATE_JOB"
|
|
122
|
+
mkdir -p "$root/.devenv" "$root/.direnv" "$root/workspace"
|
|
123
|
+
sudo mount --bind "$GITHUB_WORKSPACE" "$root/workspace"
|
|
124
|
+
rm -rf tooling/direnv/.devenv tooling/direnv/.direnv
|
|
125
|
+
ln -s "$root/.devenv" tooling/direnv/.devenv
|
|
126
|
+
ln -s "$root/.direnv" tooling/direnv/.direnv
|
|
127
|
+
echo "DEVENV_WORKDIR=$root/workspace" >> "$GITHUB_ENV"
|
|
128
|
+
echo "devenv state: $root (workspace bind-mounted at $root/workspace)"
|
|
129
|
+
|
|
130
|
+
- name: 📂 Devenv runs in the checkout
|
|
131
|
+
if: steps.runner-kind.outputs.host != 'true'
|
|
132
|
+
shell: bash
|
|
133
|
+
run: echo "DEVENV_WORKDIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
|
|
134
|
+
|
|
98
135
|
# Devenv segment (own key): tooling/direnv/.devenv + .direnv — includes
|
|
99
|
-
# nix-eval-cache.db. Independent of the store cache;
|
|
100
|
-
#
|
|
136
|
+
# nix-eval-cache.db. Independent of the store cache; restored on ephemeral
|
|
137
|
+
# runners whether or not the store cache hit. Missing store paths are fine:
|
|
101
138
|
# devenv verifies shell drv/out exist on eval-cache hit and invalidates +
|
|
102
139
|
# re-evals / realizes when GC removed them. Packages still come from host
|
|
103
140
|
# store, the store cache, or Cachix — this blob only skips evaluation.
|
|
104
141
|
- name: 🧊 Cache devenv and direnv
|
|
105
142
|
id: devenv-cache
|
|
143
|
+
if: steps.runner-kind.outputs.host != 'true'
|
|
106
144
|
uses: ./.github/actions/cache-nix-devenv
|
|
107
145
|
|
|
108
146
|
# Single-user Nix unpacked into /nix in seconds: no installer volume, no
|
|
@@ -169,7 +207,7 @@ runs:
|
|
|
169
207
|
# until the check existed.
|
|
170
208
|
- name: 🧰 Install devenv
|
|
171
209
|
shell: bash
|
|
172
|
-
working-directory: tooling/direnv
|
|
210
|
+
working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
|
|
173
211
|
env:
|
|
174
212
|
SMOO_HOST_RUNNER: ${{ steps.runner-kind.outputs.host }}
|
|
175
213
|
run: ./github-actions-bootstrap.sh install-devenv
|
|
@@ -186,5 +224,5 @@ runs:
|
|
|
186
224
|
|
|
187
225
|
- name: 🐚 Build devenv shell
|
|
188
226
|
shell: bash
|
|
189
|
-
working-directory: tooling/direnv
|
|
227
|
+
working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
|
|
190
228
|
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.13",
|
|
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.7",
|
|
68
68
|
"@smoothbricks/validation": "0.1.8",
|
|
69
69
|
"commander": "^14.0.3",
|
|
70
70
|
"make-synchronized": "^0.8.0",
|
package/src/lib/json.ts
CHANGED
|
@@ -134,6 +134,19 @@ export interface PackageCargoGitOrigin {
|
|
|
134
134
|
* origin is fetched as declared.
|
|
135
135
|
*/
|
|
136
136
|
internalMirror?: string;
|
|
137
|
+
/**
|
|
138
|
+
* SSH spellings of the same forge that managed CI rewrites onto
|
|
139
|
+
* `internalMirror` as well, e.g. `ssh://forgejo@forge.example.net:2223/`
|
|
140
|
+
* as Cargo and uv pin a git dependency. A runner holds the read token the
|
|
141
|
+
* mirror accepts and no SSH key at all, so every spelling a lockfile can
|
|
142
|
+
* carry needs its own rewrite: git matches `insteadOf` values as literal
|
|
143
|
+
* URL prefixes and infers no spelling from another — not the SSH host from
|
|
144
|
+
* the HTTPS one, and not the userless form from the one carrying a user.
|
|
145
|
+
* Each entry is a credential-free `ssh://` origin without a path; the SSH
|
|
146
|
+
* user is part of the spelling and stays in it. Requires
|
|
147
|
+
* `internalMirror`, since the entry is a rewrite source and nothing else.
|
|
148
|
+
*/
|
|
149
|
+
sshOrigins?: string[];
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
/**
|
|
@@ -153,6 +166,43 @@ export interface PackagePrivateNpmConfig {
|
|
|
153
166
|
publishTokenEnv?: string;
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Declared self-hosted Nx remote cache (`smoo.remoteCache`). Nx enables its
|
|
171
|
+
* HTTP cache on a nonempty NX_SELF_HOSTED_REMOTE_CACHE_SERVER and reads the
|
|
172
|
+
* credential from NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN; this declaration
|
|
173
|
+
* is what puts that pair in a managed CI job's environment and in a developer
|
|
174
|
+
* shell (tooling/direnv/secret-references.ts). Absent means every workspace
|
|
175
|
+
* keeps its own local cache and nothing is shared.
|
|
176
|
+
*
|
|
177
|
+
* The pair is emitted whole or not at all, because Nx accepts only 200 or 404
|
|
178
|
+
* from a cache server: a server without a working credential fails every task
|
|
179
|
+
* with 401 rather than missing quietly.
|
|
180
|
+
*/
|
|
181
|
+
export interface PackageRemoteCacheConfig {
|
|
182
|
+
/**
|
|
183
|
+
* Origin every runner and developer machine can reach, e.g.
|
|
184
|
+
* `https://nx-cache.example.net`. Credential-free, no path, and no trailing
|
|
185
|
+
* slash — Nx appends `/v1/cache/<hash>`, so a trailing slash requests
|
|
186
|
+
* `//v1/cache/<hash>`, which is a different route and answers 404 forever.
|
|
187
|
+
*/
|
|
188
|
+
server: string;
|
|
189
|
+
/**
|
|
190
|
+
* Origin an internal runner reaches instead of `server`, e.g.
|
|
191
|
+
* `http://10.89.0.1:8765` across a container bridge. Same declaration as a
|
|
192
|
+
* git origin's `internalMirror`: declaring it says this repository's managed
|
|
193
|
+
* runners are inside that network, so managed CI uses it and shells outside
|
|
194
|
+
* keep `server`. Omitted means CI uses `server` too.
|
|
195
|
+
*/
|
|
196
|
+
internalServer?: string;
|
|
197
|
+
/**
|
|
198
|
+
* Repository secret holding the cache token, and the same variable name a
|
|
199
|
+
* developer shell resolves locally (an ambient value or a `smoo.secrets`
|
|
200
|
+
* entry). A read-only token here keeps an untrusted context reading the
|
|
201
|
+
* cache without being able to publish into it.
|
|
202
|
+
*/
|
|
203
|
+
tokenSecret: RepositorySecretName;
|
|
204
|
+
}
|
|
205
|
+
|
|
156
206
|
/** Local bootstrap fallback; existing environment values always take precedence. */
|
|
157
207
|
export interface PackageSecretCommand {
|
|
158
208
|
/** Executed directly, without a shell; stdout supplies the secret value. */
|
|
@@ -162,6 +212,8 @@ export interface PackageSecretCommand {
|
|
|
162
212
|
export interface PackageSmooConfig {
|
|
163
213
|
github?: PackageSmooGithub;
|
|
164
214
|
privateNpm?: PackagePrivateNpmConfig;
|
|
215
|
+
/** Declared self-hosted Nx remote cache; absent means local caching only. */
|
|
216
|
+
remoteCache?: PackageRemoteCacheConfig;
|
|
165
217
|
/** Provider-neutral local secret commands. CI supplies these variables externally. */
|
|
166
218
|
secrets?: Record<string, PackageSecretCommand>;
|
|
167
219
|
}
|