@smoothbricks/cli 0.11.18 → 0.11.20
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 +91 -11
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +28 -4
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +6 -20
- package/dist/lib/deploy-tags.d.ts +17 -3
- package/dist/lib/deploy-tags.d.ts.map +1 -1
- package/dist/lib/deploy-tags.js +23 -4
- package/dist/lib/json.d.ts +22 -1
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +15 -3
- package/dist/monorepo/cargo-policy.d.ts +17 -0
- package/dist/monorepo/cargo-policy.d.ts.map +1 -1
- package/dist/monorepo/cargo-policy.js +73 -1
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +4 -2
- package/dist/monorepo/managed-files.d.ts +5 -2
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +35 -21
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +6 -1
- package/dist/nx/index.d.ts +6 -0
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +14 -0
- package/dist/secrets/commands.d.ts +9 -5
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +122 -49
- package/dist/secrets/index.d.ts +42 -56
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +52 -79
- package/dist/secrets/resolver.d.ts +59 -0
- package/dist/secrets/resolver.d.ts.map +1 -0
- package/dist/secrets/resolver.js +100 -0
- package/dist/secrets/run.d.ts +23 -0
- package/dist/secrets/run.d.ts.map +1 -0
- package/dist/secrets/run.js +130 -0
- package/dist/secrets/status.d.ts +177 -0
- package/dist/secrets/status.d.ts.map +1 -0
- package/dist/secrets/status.js +724 -0
- package/dist/wrangler/deploy-stage.d.ts +1 -1
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +22 -20
- package/dist/wrangler/deployed-version.d.ts.map +1 -1
- package/dist/wrangler/deployed-version.js +7 -12
- package/dist/wrangler/flat-config.d.ts +1 -4
- package/dist/wrangler/flat-config.d.ts.map +1 -1
- package/dist/wrangler/flat-config.js +98 -44
- package/dist/wrangler/prepare-env.d.ts +4 -3
- package/dist/wrangler/prepare-env.d.ts.map +1 -1
- package/dist/wrangler/prepare-env.js +7 -5
- package/dist/wrangler/source-config.d.ts +24 -0
- package/dist/wrangler/source-config.d.ts.map +1 -0
- package/dist/wrangler/source-config.js +110 -0
- package/dist/wrangler/stage.d.ts +55 -23
- package/dist/wrangler/stage.d.ts.map +1 -1
- package/dist/wrangler/stage.js +81 -162
- package/managed/raw/tooling/direnv/devenv.smoo.nix +19 -3
- package/managed/raw/tooling/direnv/secret-references.ts +280 -76
- package/managed/raw/tooling/direnv/setup-environment.ts +58 -2
- package/managed/raw/tsconfig.lib.json +28 -0
- package/package.json +7 -2
- package/src/cli.ts +34 -5
- package/src/github-ci/index.test.ts +6 -7
- package/src/github-ci/index.ts +11 -19
- package/src/lib/deploy-tags.ts +22 -4
- package/src/lib/json.ts +23 -1
- package/src/monorepo/cargo-policy.test.ts +91 -1
- package/src/monorepo/cargo-policy.ts +87 -1
- package/src/monorepo/index.ts +8 -2
- package/src/monorepo/managed-files.test.ts +69 -37
- package/src/monorepo/managed-files.ts +38 -21
- package/src/monorepo/packs/index.ts +10 -1
- package/src/monorepo/secret-references.test.ts +412 -6
- package/src/monorepo/setup-environment.test.ts +181 -0
- package/src/nx/index.test.ts +8 -0
- package/src/nx/index.ts +20 -0
- package/src/secrets/commands.test.ts +186 -4
- package/src/secrets/commands.ts +142 -51
- package/src/secrets/index.test.ts +4 -10
- package/src/secrets/index.ts +54 -115
- package/src/secrets/resolver.ts +130 -0
- package/src/secrets/run.test.ts +359 -0
- package/src/secrets/run.ts +148 -0
- package/src/secrets/status.test.ts +164 -0
- package/src/secrets/status.ts +297 -0
- package/src/wrangler/deploy-stage.test.ts +95 -2
- package/src/wrangler/deploy-stage.ts +26 -23
- package/src/wrangler/deployed-version.ts +7 -11
- package/src/wrangler/flat-config.test.ts +9 -4
- package/src/wrangler/flat-config.ts +1 -20
- package/src/wrangler/format-parity.test.ts +433 -0
- package/src/wrangler/prepare-env.ts +7 -5
- package/src/wrangler/source-config.test.ts +102 -0
- package/src/wrangler/source-config.ts +110 -0
- package/src/wrangler/stage.test.ts +61 -32
- package/src/wrangler/stage.ts +124 -173
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { decode, findRepoRoot, printCommandOutput } from './lib/run.js';
|
|
|
7
7
|
import { ensureChromium } from './playwright/index.js';
|
|
8
8
|
import { resolvePrConflicts } from './pr/index.js';
|
|
9
9
|
import { secretsSet, secretsStatus, secretsSync } from './secrets/commands.js';
|
|
10
|
+
import { secretsRun } from './secrets/run.js';
|
|
10
11
|
import { cleanupPullRequest, deployStage } from './wrangler/deploy-stage.js';
|
|
11
12
|
import { deployedVersion } from './wrangler/deployed-version.js';
|
|
12
13
|
import { scaffold } from './wrangler/scaffold.js';
|
|
@@ -74,6 +75,11 @@ function buildProgram(): Command {
|
|
|
74
75
|
.description('SmoothBricks monorepo tooling')
|
|
75
76
|
.version(cliPackageVersion, '-v, --version', 'print smoo version')
|
|
76
77
|
.exitOverride()
|
|
78
|
+
// `smoo secrets run <group> <command...>` hands the rest of argv to a
|
|
79
|
+
// child verbatim, flags included. Commander only stops parsing after the
|
|
80
|
+
// first operand when positional options are enabled here, on the parent
|
|
81
|
+
// of the command that declares `passThroughOptions`.
|
|
82
|
+
.enablePositionalOptions()
|
|
77
83
|
.showHelpAfterError();
|
|
78
84
|
|
|
79
85
|
const monorepo = program.command('monorepo').description('Manage SmoothBricks-style monorepos');
|
|
@@ -529,11 +535,29 @@ function buildProgram(): Command {
|
|
|
529
535
|
|
|
530
536
|
const secrets = program
|
|
531
537
|
.command('secrets')
|
|
532
|
-
.description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds')
|
|
538
|
+
.description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds')
|
|
539
|
+
.enablePositionalOptions();
|
|
540
|
+
secrets
|
|
541
|
+
.command('run [group] [command...]')
|
|
542
|
+
.description(
|
|
543
|
+
'Run one command with one group of declared secrets in its environment. Shell entry resolves the ' +
|
|
544
|
+
'`shell` group only, so a registry credential or the Nx cache token is resolved here, by the command ' +
|
|
545
|
+
'that needs it, instead of by every direnv reload. The group is required and positional: with none, ' +
|
|
546
|
+
'this lists the groups this repository declares and the secrets in each',
|
|
547
|
+
)
|
|
548
|
+
// Everything after the group reaches the child verbatim, flags included:
|
|
549
|
+
// `smoo secrets run registry bun add -d @acme/x` must run `bun add -d
|
|
550
|
+
// @acme/x`, not lose `-d` to this command's own parser.
|
|
551
|
+
.passThroughOptions()
|
|
552
|
+
.action(async (group: string | undefined, command: string[]) => {
|
|
553
|
+
process.exitCode = await secretsRun(await findRepoRoot(), group, command);
|
|
554
|
+
});
|
|
533
555
|
secrets
|
|
534
556
|
.command('status')
|
|
535
557
|
.description(
|
|
536
|
-
'Show every declared secret with the scopes holding it
|
|
558
|
+
'Show every declared secret with the scopes holding it. Exits 0 when every secret a managed workflow ' +
|
|
559
|
+
'passes has a value in a scope the job reads, and 1 when one does not or when the repository, its ' +
|
|
560
|
+
'secrets or a stage declaration could not be read',
|
|
537
561
|
)
|
|
538
562
|
.option(
|
|
539
563
|
'-R, --repo <owner/name|remote>',
|
|
@@ -543,8 +567,13 @@ function buildProgram(): Command {
|
|
|
543
567
|
'--env <environment>',
|
|
544
568
|
"also read this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
545
569
|
)
|
|
546
|
-
.
|
|
547
|
-
|
|
570
|
+
.option(
|
|
571
|
+
'--json',
|
|
572
|
+
'write one JSON document to stdout instead of the table, for another tool to read; the exit code is ' +
|
|
573
|
+
'unchanged, so a status that refuses still refuses',
|
|
574
|
+
)
|
|
575
|
+
.action(async (options: { repo?: string; env?: string; json?: boolean }) => {
|
|
576
|
+
process.exitCode = await secretsStatus(await findRepoRoot(), options);
|
|
548
577
|
});
|
|
549
578
|
secrets
|
|
550
579
|
.command('set [name]')
|
|
@@ -586,7 +615,7 @@ function buildProgram(): Command {
|
|
|
586
615
|
wrangler
|
|
587
616
|
.command('deploy-stage')
|
|
588
617
|
.requiredOption('--stage <stage>', 'staging, production, or prN')
|
|
589
|
-
.option('--config <path>',
|
|
618
|
+
.option('--config <path>', "deploy a build-generated flat wrangler.json instead of the project's own config")
|
|
590
619
|
.option('--version-endpoint <url>', 'URL served by this worker whose trimmed body is the running version tag')
|
|
591
620
|
.action(async (options: { stage: string; config?: string; versionEndpoint?: string }) => {
|
|
592
621
|
await deployStage(process.cwd(), {
|
|
@@ -847,8 +847,11 @@ describe('event-aware stage deployment', () => {
|
|
|
847
847
|
).toThrow(/same-repository/);
|
|
848
848
|
});
|
|
849
849
|
|
|
850
|
-
it('selects stage
|
|
850
|
+
it('selects tagged stage projects plus staging-only infrastructure, and never an untagged one', async () => {
|
|
851
851
|
const definitions: Record<string, unknown> = {
|
|
852
|
+
// A private wrangler project the plugin gave a deploy target and nobody tagged: deployable
|
|
853
|
+
// by hand, invisible to CI. Reading the command instead deployed whatever held a wrangler
|
|
854
|
+
// manifest, including a published library that ships one to document a binding.
|
|
852
855
|
app: {
|
|
853
856
|
targets: {
|
|
854
857
|
deploy: { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
|
|
@@ -875,18 +878,15 @@ describe('event-aware stage deployment', () => {
|
|
|
875
878
|
const loadProject = async (project: string) => definitions[project];
|
|
876
879
|
|
|
877
880
|
await expect(selectStageDeployProjects(candidates, 'staging', undefined, loadProject)).resolves.toEqual([
|
|
878
|
-
'app',
|
|
879
881
|
'app-backend',
|
|
880
882
|
'e2e-mail-capture',
|
|
881
883
|
'website',
|
|
882
884
|
]);
|
|
883
885
|
await expect(selectStageDeployProjects(candidates, 'pr123', undefined, loadProject)).resolves.toEqual([
|
|
884
|
-
'app',
|
|
885
886
|
'app-backend',
|
|
886
887
|
'website',
|
|
887
888
|
]);
|
|
888
889
|
await expect(selectStageDeployProjects(candidates, 'production', undefined, loadProject)).resolves.toEqual([
|
|
889
|
-
'app',
|
|
890
890
|
'app-backend',
|
|
891
891
|
'website',
|
|
892
892
|
]);
|
|
@@ -1303,15 +1303,14 @@ describe('selectStageDeployProjects with a required tag', () => {
|
|
|
1303
1303
|
};
|
|
1304
1304
|
const loadProject = async (project: string) => definitions[project];
|
|
1305
1305
|
|
|
1306
|
-
it('keeps only projects carrying the tag, on top of the stage
|
|
1306
|
+
it('keeps only projects carrying the tag, on top of the stage rules', async () => {
|
|
1307
1307
|
await expect(
|
|
1308
1308
|
selectStageDeployProjects(Object.keys(definitions), 'production', 'production-push-deploy-target', loadProject),
|
|
1309
1309
|
).resolves.toEqual(['website']);
|
|
1310
1310
|
});
|
|
1311
1311
|
|
|
1312
|
-
it('selects the
|
|
1312
|
+
it('selects only the tagged project on every stage, required tag or not', async () => {
|
|
1313
1313
|
await expect(selectStageDeployProjects(Object.keys(definitions), 'pr12', undefined, loadProject)).resolves.toEqual([
|
|
1314
|
-
'app',
|
|
1315
1314
|
'website',
|
|
1316
1315
|
]);
|
|
1317
1316
|
});
|
package/src/github-ci/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { appendFile } from 'node:fs/promises';
|
|
|
4
4
|
import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
|
|
5
5
|
import { $ } from 'bun';
|
|
6
6
|
import typia from 'typia';
|
|
7
|
-
import {
|
|
7
|
+
import { PERMANENT_DEPLOY_TAG, STAGING_DEPLOY_TAG, stageDeploysProject } from '../lib/deploy-tags.js';
|
|
8
8
|
import {
|
|
9
9
|
ciPushBranches,
|
|
10
10
|
isNonEmpty,
|
|
@@ -35,16 +35,14 @@ export interface GithubActionsEventPayload {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* What a deploy candidate is READ for: its tags. `nx show projects --withTarget
|
|
40
|
+
* deploy` already established the target, and the tags decide the rest, so a
|
|
41
|
+
* project whose `tags` is not a string array fails the parse instead of being
|
|
42
|
+
* silently untagged.
|
|
43
|
+
*/
|
|
38
44
|
interface NxProjectDeployTarget {
|
|
39
|
-
tags?:
|
|
40
|
-
targets?: {
|
|
41
|
-
deploy?: {
|
|
42
|
-
command?: unknown;
|
|
43
|
-
options?: {
|
|
44
|
-
command?: unknown;
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
};
|
|
45
|
+
tags?: string[];
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
const parseGithubActionsEvent = typia.json.createIsParse<GithubActionsEventPayload>();
|
|
@@ -570,16 +568,10 @@ export async function selectStageDeployProjects(
|
|
|
570
568
|
for (const project of candidates) {
|
|
571
569
|
const definition = await loadProject(project);
|
|
572
570
|
if (!isNxProjectDeployTarget(definition)) continue;
|
|
573
|
-
const
|
|
574
|
-
const commandValue = deploy?.options?.command ?? deploy?.command;
|
|
575
|
-
const tags = Array.isArray(definition.tags)
|
|
576
|
-
? definition.tags.filter((tag): tag is string => typeof tag === 'string')
|
|
577
|
-
: [];
|
|
578
|
-
const isStageDerived = isStageDerivedDeploy(tags, typeof commandValue === 'string' ? commandValue : undefined);
|
|
579
|
-
const isStagingOnly = tags.includes(STAGING_DEPLOY_TAG);
|
|
571
|
+
const tags = definition.tags;
|
|
580
572
|
// A required tag narrows the stage rules; it never selects a project they exclude.
|
|
581
|
-
if (requireTag && !tags
|
|
582
|
-
if (
|
|
573
|
+
if (requireTag && !tags?.includes(requireTag)) continue;
|
|
574
|
+
if (stageDeploysProject(tags, stage)) {
|
|
583
575
|
selected.push(project);
|
|
584
576
|
}
|
|
585
577
|
}
|
package/src/lib/deploy-tags.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The Nx tags that drive stage deploys. CI selection (github-ci), workflow generation (monorepo)
|
|
2
2
|
// and the graph readers must agree on these, so they live here rather than with any one of them.
|
|
3
3
|
|
|
4
|
+
import type { DeploymentStage } from '../wrangler/stage.js';
|
|
5
|
+
|
|
4
6
|
/** Deployed on every stage: one command that deploys whichever stage it is given. */
|
|
5
7
|
const STAGE_DEPLOY_TAG = 'stage-deploy-target';
|
|
6
8
|
|
|
@@ -14,9 +16,25 @@ export const PERMANENT_DEPLOY_TAG = 'permanent-deploy-target';
|
|
|
14
16
|
export const PRODUCTION_PUSH_DEPLOY_TAG = 'production-push-deploy-target';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
|
-
* Whether
|
|
18
|
-
*
|
|
19
|
+
* Whether the stage flow deploys a project, from its TAGS alone — the one rule
|
|
20
|
+
* CI selection and workflow generation both read, so a generated deploy job and
|
|
21
|
+
* the projects that job deploys cannot disagree.
|
|
22
|
+
*
|
|
23
|
+
* Having a `deploy` target is not that answer. A deploy target says a project
|
|
24
|
+
* CAN be deployed; only a tag says CI does it. A published library ships a
|
|
25
|
+
* wrangler manifest to document a Durable Object binding for its consumers, and
|
|
26
|
+
* that manifest is structurally identical to a deployable worker's — so the
|
|
27
|
+
* plugin infers `deploy` from one, and a repository that deploys nothing from CI
|
|
28
|
+
* would otherwise generate a deploy job, with cloud credentials, for it.
|
|
29
|
+
*
|
|
30
|
+
* The three tags compose as precedence, not as a set: `permanent` excludes a
|
|
31
|
+
* project from every stage, `staging` narrows it to one, `stage` generalizes it
|
|
32
|
+
* to all. `production-push` selects WITHIN this answer (see `--select-tag`) and
|
|
33
|
+
* never widens it.
|
|
19
34
|
*/
|
|
20
|
-
export function
|
|
21
|
-
|
|
35
|
+
export function stageDeploysProject(tags: string[] | undefined, stage: DeploymentStage): boolean {
|
|
36
|
+
if (tags === undefined) return false;
|
|
37
|
+
if (tags.includes(PERMANENT_DEPLOY_TAG)) return false;
|
|
38
|
+
if (tags.includes(STAGING_DEPLOY_TAG)) return stage === 'staging';
|
|
39
|
+
return tags.includes(STAGE_DEPLOY_TAG);
|
|
22
40
|
}
|
package/src/lib/json.ts
CHANGED
|
@@ -212,10 +212,32 @@ export interface PackageRemoteCacheConfig {
|
|
|
212
212
|
tokenSecret: RepositorySecretName;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* A `smoo.secrets` group: which operation resolves the credential. One argv
|
|
217
|
+
* word of `smoo secrets run <group> <command...>`, so whitespace would make
|
|
218
|
+
* that command unwritable. Only the shape is validated — the values are the
|
|
219
|
+
* repository's, and a new group must not need a new smoo release.
|
|
220
|
+
*/
|
|
221
|
+
export type SecretGroupName = string & typia.tags.MinLength<1> & typia.tags.Pattern<'^\\S+$'>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Local bootstrap fallback; existing environment values always take
|
|
225
|
+
* precedence, and CI injects these variables instead of running commands.
|
|
226
|
+
*
|
|
227
|
+
* Shell entry resolves the `shell` group and nothing else; every other group
|
|
228
|
+
* is resolved by `smoo secrets run <group> <command...>`, the one command
|
|
229
|
+
* that needs it. The group is DERIVED from what this manifest already
|
|
230
|
+
* declares — a variable `.npmrc` interpolates as `${VAR}` is `registry`, the
|
|
231
|
+
* variable `smoo.remoteCache.tokenSecret` names is `nx-cache`, everything
|
|
232
|
+
* else is `shell` — so `group` below is only for what those declarations
|
|
233
|
+
* cannot say. The routing lives in tooling/direnv/secret-references.ts,
|
|
234
|
+
* whose header states the rule and the derivation.
|
|
235
|
+
*/
|
|
216
236
|
export interface PackageSecretCommand {
|
|
217
237
|
/** Executed directly, without a shell; stdout supplies the secret value. */
|
|
218
238
|
command: NonEmptyArray<string>;
|
|
239
|
+
/** Overrides the derived group; absent derives one from this manifest and `.npmrc`. */
|
|
240
|
+
group?: SecretGroupName;
|
|
219
241
|
}
|
|
220
242
|
|
|
221
243
|
export interface PackageSmooConfig {
|
|
@@ -3,7 +3,13 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
-
import
|
|
6
|
+
import type { ProjectTargets } from '../nx/index.js';
|
|
7
|
+
import {
|
|
8
|
+
applyCargoFeatureUnification,
|
|
9
|
+
type CargoHakariShell,
|
|
10
|
+
validateCargoCachePolicy,
|
|
11
|
+
validateCargoToolchainInputs,
|
|
12
|
+
} from './cargo-policy.js';
|
|
7
13
|
|
|
8
14
|
async function withFixture<T>(files: Record<string, string>, callback: (root: string) => T): Promise<T> {
|
|
9
15
|
const root = await mkdtemp(join(tmpdir(), 'smoo-cargo-policy-'));
|
|
@@ -33,6 +39,20 @@ async function check(
|
|
|
33
39
|
});
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
async function checkToolchain(
|
|
43
|
+
files: Record<string, string>,
|
|
44
|
+
projects: ProjectTargets[],
|
|
45
|
+
): Promise<{ failures: number; messages: string[] }> {
|
|
46
|
+
return withFixture(files, (root) => {
|
|
47
|
+
const captured = captureErrors();
|
|
48
|
+
try {
|
|
49
|
+
return { failures: validateCargoToolchainInputs(root, projects), messages: captured.messages };
|
|
50
|
+
} finally {
|
|
51
|
+
captured.restore();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
/** Records what update would run, and answers `verify` however the test needs. */
|
|
37
57
|
function recordingHakari(
|
|
38
58
|
verify: { code: number; output?: string; missing?: boolean } = { code: 0 },
|
|
@@ -389,3 +409,73 @@ describe('Cargo feature unification update', () => {
|
|
|
389
409
|
);
|
|
390
410
|
});
|
|
391
411
|
});
|
|
412
|
+
|
|
413
|
+
describe('cargo toolchain identity policy', () => {
|
|
414
|
+
const cargoLint = (inputs: string[]): ProjectTargets => ({
|
|
415
|
+
project: 'codebase',
|
|
416
|
+
root: '.',
|
|
417
|
+
targets: ['cargo-lint'],
|
|
418
|
+
targetCache: new Map([['cargo-lint', true]]),
|
|
419
|
+
targetInputs: new Map([['cargo-lint', inputs]]),
|
|
420
|
+
targetOptions: new Map([['cargo-lint', { command: 'cargo --frozen clippy --workspace -- -D warnings' }]]),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// The failure this policy exists for: a hand-written input list replaces the
|
|
424
|
+
// inferred one, so the target stops hashing the pin and a toolchain bump
|
|
425
|
+
// leaves its cached verdict standing.
|
|
426
|
+
it('refuses a cached cargo target whose declared inputs drop the pin', async () => {
|
|
427
|
+
const { failures, messages } = await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['rustWorkspace'])]);
|
|
428
|
+
expect(failures).toBe(1);
|
|
429
|
+
expect(messages).toEqual([
|
|
430
|
+
'codebase:cargo-lint: cached cargo target hashes no toolchain pin, so a toolchain bump cannot invalidate it. ' +
|
|
431
|
+
'Add "cargoToolchain" to its inputs, or to the named input it uses.',
|
|
432
|
+
]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('accepts the pin reached directly, through a named input, or by the name inference defines', async () => {
|
|
436
|
+
const viaNamedInput = JSON.stringify({
|
|
437
|
+
namedInputs: { rustWorkspace: ['{workspaceRoot}/Cargo.toml', '{workspaceRoot}/tooling/direnv/devenv.lock'] },
|
|
438
|
+
});
|
|
439
|
+
expect(await checkToolchain({ 'nx.json': viaNamedInput }, [cargoLint(['rustWorkspace'])])).toMatchObject({
|
|
440
|
+
failures: 0,
|
|
441
|
+
});
|
|
442
|
+
expect(
|
|
443
|
+
await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['{workspaceRoot}/tooling/direnv/devenv.lock'])]),
|
|
444
|
+
).toMatchObject({ failures: 0 });
|
|
445
|
+
expect(await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['cargoToolchain'])])).toMatchObject({
|
|
446
|
+
failures: 0,
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// A negated fileset REMOVES a path from the hash. Reading one as the pin
|
|
451
|
+
// would accept the exact declaration that guarantees the bug.
|
|
452
|
+
it('does not accept an excluded lock as the pin', async () => {
|
|
453
|
+
expect(
|
|
454
|
+
await checkToolchain({ 'nx.json': '{}\n' }, [cargoLint(['!{workspaceRoot}/tooling/direnv/devenv.lock'])]),
|
|
455
|
+
).toMatchObject({ failures: 1 });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Only what Nx can restore, and only what runs cargo: an uncached warm-up or
|
|
459
|
+
// a TypeScript build has no stale artifact for a toolchain bump to strand.
|
|
460
|
+
it('governs cached cargo targets only', async () => {
|
|
461
|
+
const uncached: ProjectTargets = {
|
|
462
|
+
...cargoLint([]),
|
|
463
|
+
targetCache: new Map([['cargo-lint', false]]),
|
|
464
|
+
};
|
|
465
|
+
const notCargo: ProjectTargets = {
|
|
466
|
+
...cargoLint([]),
|
|
467
|
+
targets: ['tsc-js'],
|
|
468
|
+
targetCache: new Map([['tsc-js', true]]),
|
|
469
|
+
targetInputs: new Map([['tsc-js', ['default']]]),
|
|
470
|
+
targetOptions: new Map([['tsc-js', { command: 'ttsc --build' }]]),
|
|
471
|
+
};
|
|
472
|
+
expect(await checkToolchain({ 'nx.json': '{}\n' }, [uncached, notCargo])).toMatchObject({ failures: 0 });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// A cyclic named input is a repository mistake, not a reason to hang the
|
|
476
|
+
// whole validation run before it can report anything at all.
|
|
477
|
+
it('terminates on a named input that references itself', async () => {
|
|
478
|
+
const cyclic = JSON.stringify({ namedInputs: { rustWorkspace: ['rustWorkspace'] } });
|
|
479
|
+
expect(await checkToolchain({ 'nx.json': cyclic }, [cargoLint(['rustWorkspace'])])).toMatchObject({ failures: 1 });
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { type Dirent, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { CARGO_TOOLCHAIN_NAMED_INPUT, isCargoToolchainInput } from '@smoothbricks/nx-plugin/cargo-toolchain-policy';
|
|
4
5
|
import typia from 'typia';
|
|
5
|
-
import { parsePackageJsonText } from '../lib/json.js';
|
|
6
|
+
import { type NxJson, type NxTargetOptions, parseNxJsonText, parsePackageJsonText } from '../lib/json.js';
|
|
7
|
+
import type { ProjectTargets } from '../nx/index.js';
|
|
6
8
|
|
|
7
9
|
interface CargoProfile {
|
|
8
10
|
inherits?: string;
|
|
@@ -869,6 +871,90 @@ export function validateCargoCachePolicy(root: string, options: CargoPolicyOptio
|
|
|
869
871
|
return failures;
|
|
870
872
|
}
|
|
871
873
|
|
|
874
|
+
/** A command that compiles or checks Rust, whichever driver spells it. */
|
|
875
|
+
const RUNS_CARGO = /(?:^|[\s;&|])cargo[\s-]|\bnapi build\b/;
|
|
876
|
+
|
|
877
|
+
const CARGO_TOOLCHAIN_FIX = `Add "${CARGO_TOOLCHAIN_NAMED_INPUT}" to its inputs, or to the named input it uses`;
|
|
878
|
+
|
|
879
|
+
function commandsOf(options: NxTargetOptions | undefined): string {
|
|
880
|
+
const command: unknown = options?.command;
|
|
881
|
+
const commands: unknown = options?.commands;
|
|
882
|
+
return [
|
|
883
|
+
typeof command === 'string' ? command : '',
|
|
884
|
+
...(Array.isArray(commands) ? commands.filter((entry) => typeof entry === 'string') : []),
|
|
885
|
+
].join('\n');
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Every fileset one declared input list reaches, with nx.json's named inputs
|
|
890
|
+
* expanded. The plugin's own `cargoToolchain` is defined per PROJECT and wins
|
|
891
|
+
* over nx.json, so naming it counts without an entry here; a repository that
|
|
892
|
+
* redefines it workspace-wide is expanded and judged on what it resolves to.
|
|
893
|
+
*/
|
|
894
|
+
function resolveInputFilesets(
|
|
895
|
+
declared: readonly string[],
|
|
896
|
+
namedInputs: NonNullable<NxJson['namedInputs']>,
|
|
897
|
+
seen: Set<string> = new Set(),
|
|
898
|
+
): string[] {
|
|
899
|
+
const filesets: string[] = [];
|
|
900
|
+
for (const entry of declared) {
|
|
901
|
+
const name = entry.startsWith('^') ? entry.slice(1) : entry;
|
|
902
|
+
const definition = namedInputs[name];
|
|
903
|
+
if (definition === undefined) {
|
|
904
|
+
filesets.push(entry);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (seen.has(name)) continue;
|
|
908
|
+
seen.add(name);
|
|
909
|
+
const expanded = typeof definition === 'string' ? [definition] : definition;
|
|
910
|
+
filesets.push(
|
|
911
|
+
...resolveInputFilesets(
|
|
912
|
+
expanded.filter((value): value is string => typeof value === 'string'),
|
|
913
|
+
namedInputs,
|
|
914
|
+
seen,
|
|
915
|
+
),
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
return filesets;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* A cached cargo target must hash the toolchain that compiled it.
|
|
923
|
+
*
|
|
924
|
+
* Inference attaches the pin to every cargo target it infers, but a repository
|
|
925
|
+
* that DECLARES `inputs` for one replaces that list wholesale and silently
|
|
926
|
+
* drops it — the target then survives a toolchain bump with a stale verdict,
|
|
927
|
+
* and a pre-push cross-compile probe consults a cache entry that cannot
|
|
928
|
+
* express the change it is probing for. Measured before this policy existed:
|
|
929
|
+
* a one-byte bump to the declared pin left one repository's `cargo-lint-cross`
|
|
930
|
+
* hash unchanged.
|
|
931
|
+
*
|
|
932
|
+
* This is a generated safety property removed by a local override, which no
|
|
933
|
+
* amount of care at the override site will catch on its own, so it is checked
|
|
934
|
+
* against the RESOLVED graph rather than against any one file.
|
|
935
|
+
*/
|
|
936
|
+
export function validateCargoToolchainInputs(root: string, projects: readonly ProjectTargets[]): number {
|
|
937
|
+
const nxJsonPath = join(resolve(root), 'nx.json');
|
|
938
|
+
const namedInputs = existsSync(nxJsonPath)
|
|
939
|
+
? (parseNxJsonText(readFileSync(nxJsonPath, 'utf8'))?.namedInputs ?? {})
|
|
940
|
+
: {};
|
|
941
|
+
let failures = 0;
|
|
942
|
+
for (const project of projects) {
|
|
943
|
+
for (const target of [...project.targets].sort()) {
|
|
944
|
+
if (project.targetCache?.get(target) !== true) continue;
|
|
945
|
+
if (!RUNS_CARGO.test(commandsOf(project.targetOptions?.get(target)))) continue;
|
|
946
|
+
const declared = project.targetInputs?.get(target) ?? [];
|
|
947
|
+
if (declared.includes(CARGO_TOOLCHAIN_NAMED_INPUT)) continue;
|
|
948
|
+
if (resolveInputFilesets(declared, namedInputs).some(isCargoToolchainInput)) continue;
|
|
949
|
+
failures += report(
|
|
950
|
+
`${project.project}:${target}`,
|
|
951
|
+
`cached cargo target hashes no toolchain pin, so a toolchain bump cannot invalidate it. ${CARGO_TOOLCHAIN_FIX}.`,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return failures;
|
|
956
|
+
}
|
|
957
|
+
|
|
872
958
|
/**
|
|
873
959
|
* The fix `smoo monorepo update` applies for the policy above: write the
|
|
874
960
|
* nightly resolver configuration, or generate and wire the workspace-hack a
|
package/src/monorepo/index.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { printCommandOutput, run, runResult } from '../lib/run.js';
|
|
3
3
|
import { escapeRegex, getWorkspacePackages, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
|
|
4
4
|
import { readProjectTargets } from '../nx/index.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
applyCargoFeatureUnification,
|
|
7
|
+
validateCargoCachePolicy,
|
|
8
|
+
validateCargoToolchainInputs,
|
|
9
|
+
} from './cargo-policy.js';
|
|
6
10
|
import {
|
|
7
11
|
formatCommitMessage,
|
|
8
12
|
stagedDeletedPublicPackages,
|
|
@@ -133,7 +137,8 @@ export async function checkManagedFiles(root: string, options: { warn?: boolean
|
|
|
133
137
|
}
|
|
134
138
|
const results = await applyManagedFiles(root, 'check');
|
|
135
139
|
printResults(results);
|
|
136
|
-
const
|
|
140
|
+
const projectTargets = await readProjectTargets(root);
|
|
141
|
+
const resolvedTargets = resolvedTargetsByProject(projectTargets);
|
|
137
142
|
const packageFailures =
|
|
138
143
|
validateRootPackagePolicy(root) +
|
|
139
144
|
validateNxProjectNames(root) +
|
|
@@ -145,6 +150,7 @@ export async function checkManagedFiles(root: string, options: { warn?: boolean
|
|
|
145
150
|
validateWorkspaceDependencies(root, { resolvedTargetsByProject: resolvedTargets }) +
|
|
146
151
|
validateDevenvModuleImport(root) +
|
|
147
152
|
validateCargoCachePolicy(root) +
|
|
153
|
+
validateCargoToolchainInputs(root, projectTargets) +
|
|
148
154
|
validateSccachePatches(root);
|
|
149
155
|
if (results.some((result) => result.action === 'drifted') || packageFailures > 0) {
|
|
150
156
|
throw new Error('Managed monorepo files or package conventions are out of date. Run: smoo monorepo update');
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
LOCAL_SECTION_MARKER,
|
|
19
19
|
type ManagedFileContext,
|
|
20
20
|
macosPlatformArchitecturesForTest,
|
|
21
|
+
managedFileTargetsForContext,
|
|
21
22
|
managedFileTargetsForTest,
|
|
22
23
|
platformTargetGlobsForTest,
|
|
23
24
|
reinsertInlineLocalBlocksForTest,
|
|
@@ -263,14 +264,10 @@ describe('nx graph project helpers', () => {
|
|
|
263
264
|
},
|
|
264
265
|
},
|
|
265
266
|
app: {
|
|
267
|
+
// What the plugin infers for a private wrangler project, and what a published library
|
|
268
|
+
// holding a wrangler manifest used to get: a deploy target no tag enrols in CI.
|
|
266
269
|
targets: {
|
|
267
|
-
deploy: {
|
|
268
|
-
options: { command: 'echo no' },
|
|
269
|
-
configurations: {
|
|
270
|
-
staging: { command: 'wrangler deploy --env staging' },
|
|
271
|
-
production: { options: { command: 'wrangler deploy --env production' } },
|
|
272
|
-
},
|
|
273
|
-
},
|
|
270
|
+
deploy: { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
|
|
274
271
|
'package-macos': {},
|
|
275
272
|
},
|
|
276
273
|
},
|
|
@@ -296,43 +293,40 @@ describe('nx graph project helpers', () => {
|
|
|
296
293
|
expect(hasExactTargetForTest(nearMisses, 'e2e-deployment')).toBe(false);
|
|
297
294
|
});
|
|
298
295
|
|
|
299
|
-
it('
|
|
300
|
-
expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({
|
|
301
|
-
|
|
302
|
-
provider: 'cloudflare',
|
|
303
|
-
});
|
|
304
|
-
expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({
|
|
305
|
-
exists: true,
|
|
306
|
-
provider: 'cloudflare',
|
|
307
|
-
});
|
|
308
|
-
expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
|
|
296
|
+
it('deploys nothing for a wrangler deploy target no tag names', () => {
|
|
297
|
+
expect(deployTargetInfoFromProjects(sampleProjects, 'staging')).toEqual({ exists: false });
|
|
298
|
+
expect(deployTargetInfoFromProjects(sampleProjects, 'production')).toEqual({ exists: false });
|
|
309
299
|
});
|
|
310
300
|
|
|
311
|
-
it('
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
|
-
};
|
|
301
|
+
it('reads the tags as precedence: permanent excludes, staging narrows, stage generalizes', () => {
|
|
302
|
+
const deploy = { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } };
|
|
303
|
+
const stageFor = (tags: string[]) => ({
|
|
304
|
+
staging: deployTargetInfoFromProjects({ site: { tags, targets: { deploy } } }, 'staging').exists,
|
|
305
|
+
production: deployTargetInfoFromProjects({ site: { tags, targets: { deploy } } }, 'production').exists,
|
|
306
|
+
});
|
|
321
307
|
|
|
322
|
-
expect(
|
|
323
|
-
expect(
|
|
308
|
+
expect(stageFor(['stage-deploy-target'])).toEqual({ staging: true, production: true });
|
|
309
|
+
expect(stageFor(['staging-deploy-target'])).toEqual({ staging: true, production: false });
|
|
310
|
+
expect(stageFor(['permanent-deploy-target'])).toEqual({ staging: false, production: false });
|
|
311
|
+
// Both spellings on one project is a contradiction; the exclusion wins, which is what
|
|
312
|
+
// `nx show projects --exclude=tag:permanent-deploy-target` does to it in CI too.
|
|
313
|
+
expect(stageFor(['permanent-deploy-target', 'stage-deploy-target'])).toEqual({
|
|
314
|
+
staging: false,
|
|
315
|
+
production: false,
|
|
316
|
+
});
|
|
317
|
+
expect(stageFor(['npm:private'])).toEqual({ staging: false, production: false });
|
|
324
318
|
});
|
|
325
319
|
|
|
326
|
-
it('
|
|
327
|
-
const
|
|
328
|
-
site: {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
},
|
|
320
|
+
it('reads the command for the provider only: which credentials the generated job needs', () => {
|
|
321
|
+
const cloudflare: NxProjects = {
|
|
322
|
+
site: { tags: ['stage-deploy-target'], targets: { deploy: { command: 'wrangler deploy --config dist/w.json' } } },
|
|
323
|
+
};
|
|
324
|
+
const elsewhere: NxProjects = {
|
|
325
|
+
site: { tags: ['stage-deploy-target'], targets: { deploy: { command: 'bun tooling/deploy-site.ts' } } },
|
|
332
326
|
};
|
|
333
327
|
|
|
334
|
-
expect(deployTargetInfoFromProjects(
|
|
335
|
-
expect(deployTargetInfoFromProjects(
|
|
328
|
+
expect(deployTargetInfoFromProjects(cloudflare, 'staging')).toEqual({ exists: true, provider: 'cloudflare' });
|
|
329
|
+
expect(deployTargetInfoFromProjects(elsewhere, 'staging')).toEqual({ exists: true, provider: undefined });
|
|
336
330
|
});
|
|
337
331
|
|
|
338
332
|
it('finds a tag carried by any project in the graph', () => {
|
|
@@ -582,6 +576,44 @@ describe('CI workflow rendering by repo shape', () => {
|
|
|
582
576
|
expect(rendered).toContain(' deploy-production:');
|
|
583
577
|
});
|
|
584
578
|
|
|
579
|
+
it('renders no deploy job at all for a repo whose wrangler projects carry no deploy tag', () => {
|
|
580
|
+
// The regression this exists for: published libraries ship a wrangler manifest to document a
|
|
581
|
+
// Durable Object binding for their consumers, the plugin inferred a deploy target from each,
|
|
582
|
+
// and a repository that deploys nothing from CI grew a Deploy Stage step holding Cloudflare
|
|
583
|
+
// credentials, a deployments permission, and a preview-cleanup workflow.
|
|
584
|
+
const deploy = { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } };
|
|
585
|
+
const projects: NxProjects = {
|
|
586
|
+
'acme-cloudflare': { tags: ['npm:private'], targets: { deploy } },
|
|
587
|
+
'acme-documents': { tags: ['npm:public'], targets: { deploy } },
|
|
588
|
+
// A private worker, deployed by hand from a developer's shell and never by CI.
|
|
589
|
+
'acme-service': { targets: { deploy } },
|
|
590
|
+
};
|
|
591
|
+
const staging = deployTargetInfoFromProjects(projects, 'staging');
|
|
592
|
+
const production = deployTargetInfoFromProjects(projects, 'production');
|
|
593
|
+
const shape = context({
|
|
594
|
+
hasStagingDeployTargets: staging.exists,
|
|
595
|
+
stagingDeployProvider: staging.provider,
|
|
596
|
+
hasProductionDeployTargets: production.exists,
|
|
597
|
+
productionDeployProvider: production.provider,
|
|
598
|
+
hasE2eDeploymentTargets: true,
|
|
599
|
+
});
|
|
600
|
+
const ci = renderManagedWorkflowForTest('ci-workflow', shape);
|
|
601
|
+
|
|
602
|
+
expect(ci).not.toContain('Deploy Stage');
|
|
603
|
+
expect(ci).not.toContain('deployments: write');
|
|
604
|
+
expect(ci).not.toContain('CLOUDFLARE_API_TOKEN');
|
|
605
|
+
expect(ci).not.toContain('e2e-deployment');
|
|
606
|
+
expect(renderManagedWorkflowForTest('publish-workflow', shape)).not.toContain('Deploy production');
|
|
607
|
+
expect(managedFileTargetsForContext(shape)).not.toContain('.github/workflows/pr-preview-cleanup.yml');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('adds the preview cleanup workflow only where a cloudflare stage deploy makes preview stages', () => {
|
|
611
|
+
expect(managedFileTargetsForContext(deploying)).toContain('.github/workflows/pr-preview-cleanup.yml');
|
|
612
|
+
expect(managedFileTargetsForContext({ ...deploying, stagingDeployProvider: undefined })).not.toContain(
|
|
613
|
+
'.github/workflows/pr-preview-cleanup.yml',
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
|
|
585
617
|
it('threads environments and secrets from the context into the workflow', () => {
|
|
586
618
|
const rendered = renderManagedWorkflowForTest('ci-workflow', {
|
|
587
619
|
...deploying,
|