@smoothbricks/cli 0.11.17 → 0.11.18
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 +22 -4
- package/dist/cli.js +10 -7
- package/dist/monorepo/ci-workflow.d.ts +56 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +199 -2
- package/dist/monorepo/managed-files.d.ts +23 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +39 -1
- package/dist/release/github-release.d.ts +10 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +4 -5
- package/dist/secrets/commands.d.ts +52 -13
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +220 -34
- package/dist/secrets/index.d.ts +21 -1
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +110 -4
- package/dist/secrets/repository.d.ts +60 -0
- package/dist/secrets/repository.d.ts.map +1 -0
- package/dist/secrets/repository.js +145 -0
- package/dist/wrangler/cloudflare.d.ts +7 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +10 -0
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +50 -24
- package/dist/wrangler/stage-secrets.d.ts +57 -0
- package/dist/wrangler/stage-secrets.d.ts.map +1 -0
- package/dist/wrangler/stage-secrets.js +178 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
- package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
- package/package.json +2 -2
- package/src/cli.ts +33 -10
- package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
- package/src/monorepo/ci-workflow.ts +277 -2
- package/src/monorepo/managed-files.test.ts +27 -0
- package/src/monorepo/managed-files.ts +56 -2
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/github-release.ts +13 -5
- package/src/release/index.ts +4 -4
- package/src/secrets/commands.test.ts +28 -0
- package/src/secrets/commands.ts +237 -35
- package/src/secrets/index.test.ts +118 -1
- package/src/secrets/index.ts +108 -4
- package/src/secrets/repository.test.ts +98 -0
- package/src/secrets/repository.ts +164 -0
- package/src/wrangler/cloudflare.ts +18 -0
- package/src/wrangler/deploy-stage.test.ts +258 -11
- package/src/wrangler/deploy-stage.ts +70 -38
- package/src/wrangler/stage-secrets.ts +146 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type DeploymentStage } from './stage.js';
|
|
2
|
+
/**
|
|
3
|
+
* How a declaration names a stage. The fixed stages go by their own name; every `prN` stage is
|
|
4
|
+
* `preview`, because a scope is written once and pull-request numbers are not knowable in advance.
|
|
5
|
+
*/
|
|
6
|
+
export type SecretStageScope = 'staging' | 'production' | 'preview';
|
|
7
|
+
/**
|
|
8
|
+
* `smoo.wrangler.secretStages`: a declared secret name mapped to the stages it belongs to. The map
|
|
9
|
+
* answers two questions with one declaration, and both directions matter:
|
|
10
|
+
*
|
|
11
|
+
* - requirement — a stage the secret belongs to refuses to deploy without a value for it;
|
|
12
|
+
* - permission — a stage the secret does *not* belong to never receives it, however loudly the
|
|
13
|
+
* deploying shell exports it. A test-only capability exported by CI for preview stages must not
|
|
14
|
+
* ride along into production just because the variable happens to be set.
|
|
15
|
+
*
|
|
16
|
+
* A declared name absent from the map belongs to every stage. A name mapped to `[]` belongs to no
|
|
17
|
+
* stage, which is how a value that exists only for local development is declared.
|
|
18
|
+
*/
|
|
19
|
+
export type SecretStageMap = Record<string, SecretStageScope[]>;
|
|
20
|
+
/** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
|
|
21
|
+
export declare function readDeclaredSecretNames(cwd: string): string[];
|
|
22
|
+
/** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
|
|
23
|
+
export declare function readSecretStageMap(cwd: string): SecretStageMap;
|
|
24
|
+
/** Which of a project's declared secrets one stage may see, and which belong to other stages. */
|
|
25
|
+
export interface StageSecretPlan {
|
|
26
|
+
stage: DeploymentStage;
|
|
27
|
+
/** Declared names this stage requires — and the only ones a deploy of it may carry. */
|
|
28
|
+
required: string[];
|
|
29
|
+
/** Declared names scoped to other stages: withheld from this deploy even when a value is exported. */
|
|
30
|
+
withheld: string[];
|
|
31
|
+
/** The declaration itself, so a refusal can say why each name is where it is. */
|
|
32
|
+
scopes: SecretStageMap;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Splits the declared secrets by whether `stage` is in scope for each.
|
|
36
|
+
*
|
|
37
|
+
* A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
|
|
38
|
+
* real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
|
|
39
|
+
* nothing while the real secret, still absent from the map, reaches every stage.
|
|
40
|
+
*/
|
|
41
|
+
export declare function planStageSecrets(declared: string[], stage: DeploymentStage, scopes: SecretStageMap): StageSecretPlan;
|
|
42
|
+
/**
|
|
43
|
+
* Why this deploy must not proceed, or nothing when it may.
|
|
44
|
+
*
|
|
45
|
+
* Two refusals, reported together so one run names every problem:
|
|
46
|
+
*
|
|
47
|
+
* - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
|
|
48
|
+
* that never mentions a secret leaves whatever the Worker already holds — silence that reads as
|
|
49
|
+
* success while a secret introduced after the first deploy never arrives, and the code that
|
|
50
|
+
* needs it fails at runtime instead of here.
|
|
51
|
+
* - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
|
|
52
|
+
* remove it, so the scope would be nominal rather than enforced until someone deletes it.
|
|
53
|
+
*
|
|
54
|
+
* A value is never read, never formatted, and never named beyond its key.
|
|
55
|
+
*/
|
|
56
|
+
export declare function stageSecretRefusal(plan: StageSecretPlan, exported: ReadonlySet<string>, held: ReadonlySet<string>, workerName: string): string | undefined;
|
|
57
|
+
//# sourceMappingURL=stage-secrets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stage-secrets.d.ts","sourceRoot":"","sources":["../../src/wrangler/stage-secrets.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,eAAe,EAAsB,MAAM,YAAY,CAAC;AAEtE;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;AAahE,uGAAuG;AACvG,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAG7D;AAED,wGAAwG;AACxG,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAQ9D;AAED,iGAAiG;AACjG,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,sGAAsG;IACtG,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iFAAiF;IACjF,MAAM,EAAE,cAAc,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,cAAc,GAAG,eAAe,CAsBpH;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,eAAe,EACrB,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,EAC7B,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EACzB,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,SAAS,CAsBpB"}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as _accessExpressionAsString_1 from "typia/lib/internal/_accessExpressionAsString";
|
|
2
|
+
const __typia_transform__accessExpressionAsString = _accessExpressionAsString_1._accessExpressionAsString;
|
|
3
|
+
import * as _validateReport_1 from "typia/lib/internal/_validateReport";
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import typia from 'typia';
|
|
7
|
+
import { formatValidationErrors, parseJsonFileText } from '../lib/json.js';
|
|
8
|
+
import { parseDevVarsExample } from './prepare-env.js';
|
|
9
|
+
import { isPullRequestStage } from './stage.js';
|
|
10
|
+
const validateWranglerManifest = (() => {
|
|
11
|
+
const _io0 = input => undefined === input.smoo || "object" === typeof input.smoo && null !== input.smoo && false === Array.isArray(input.smoo) && _io1(input.smoo);
|
|
12
|
+
const _io1 = input => undefined === input.wrangler || "object" === typeof input.wrangler && null !== input.wrangler && false === Array.isArray(input.wrangler) && _io2(input.wrangler);
|
|
13
|
+
const _io2 = input => undefined === input.secretStages || "object" === typeof input.secretStages && null !== input.secretStages && false === Array.isArray(input.secretStages) && _io3(input.secretStages);
|
|
14
|
+
const _io3 = input => Object.keys(input).every(key => {
|
|
15
|
+
const value = input[key];
|
|
16
|
+
if (undefined === value)
|
|
17
|
+
return true;
|
|
18
|
+
return Array.isArray(value) && value.every(elem => "preview" === elem || "production" === elem || "staging" === elem);
|
|
19
|
+
});
|
|
20
|
+
const _vo0 = (input, _path, _exceptionable = true) => [undefined === input.smoo || ("object" === typeof input.smoo && null !== input.smoo && false === Array.isArray(input.smoo) || _report(_exceptionable, {
|
|
21
|
+
path: _path + ".smoo",
|
|
22
|
+
expected: "(undefined | { wrangler?: { secretStages?: SecretStageMap | undefined; } | undefined; })",
|
|
23
|
+
value: input.smoo
|
|
24
|
+
})) && _vo1(input.smoo, _path + ".smoo", true && _exceptionable) || _report(_exceptionable, {
|
|
25
|
+
path: _path + ".smoo",
|
|
26
|
+
expected: "(undefined | { wrangler?: { secretStages?: SecretStageMap | undefined; } | undefined; })",
|
|
27
|
+
value: input.smoo
|
|
28
|
+
})].every(flag => flag);
|
|
29
|
+
const _vo1 = (input, _path, _exceptionable = true) => [undefined === input.wrangler || ("object" === typeof input.wrangler && null !== input.wrangler && false === Array.isArray(input.wrangler) || _report(_exceptionable, {
|
|
30
|
+
path: _path + ".wrangler",
|
|
31
|
+
expected: "(undefined | { secretStages?: SecretStageMap | undefined; })",
|
|
32
|
+
value: input.wrangler
|
|
33
|
+
})) && _vo2(input.wrangler, _path + ".wrangler", true && _exceptionable) || _report(_exceptionable, {
|
|
34
|
+
path: _path + ".wrangler",
|
|
35
|
+
expected: "(undefined | { secretStages?: SecretStageMap | undefined; })",
|
|
36
|
+
value: input.wrangler
|
|
37
|
+
})].every(flag => flag);
|
|
38
|
+
const _vo2 = (input, _path, _exceptionable = true) => [undefined === input.secretStages || ("object" === typeof input.secretStages && null !== input.secretStages && false === Array.isArray(input.secretStages) || _report(_exceptionable, {
|
|
39
|
+
path: _path + ".secretStages",
|
|
40
|
+
expected: "(SecretStageMap | undefined)",
|
|
41
|
+
value: input.secretStages
|
|
42
|
+
})) && _vo3(input.secretStages, _path + ".secretStages", true && _exceptionable) || _report(_exceptionable, {
|
|
43
|
+
path: _path + ".secretStages",
|
|
44
|
+
expected: "(SecretStageMap | undefined)",
|
|
45
|
+
value: input.secretStages
|
|
46
|
+
})].every(flag => flag);
|
|
47
|
+
const _vo3 = (input, _path, _exceptionable = true) => [false === _exceptionable || Object.keys(input).map(key => {
|
|
48
|
+
const value = input[key];
|
|
49
|
+
if (undefined === value)
|
|
50
|
+
return true;
|
|
51
|
+
return (Array.isArray(value) || _report(_exceptionable, {
|
|
52
|
+
path: _path + __typia_transform__accessExpressionAsString(key),
|
|
53
|
+
expected: "Array<SecretStageScope>",
|
|
54
|
+
value: value
|
|
55
|
+
})) && value.map((elem, _index2) => "preview" === elem || "production" === elem || "staging" === elem || _report(_exceptionable, {
|
|
56
|
+
path: _path + __typia_transform__accessExpressionAsString(key) + "[" + _index2 + "]",
|
|
57
|
+
expected: "(\"preview\" | \"production\" | \"staging\")",
|
|
58
|
+
value: elem
|
|
59
|
+
})).every(flag => flag) || _report(_exceptionable, {
|
|
60
|
+
path: _path + __typia_transform__accessExpressionAsString(key),
|
|
61
|
+
expected: "Array<SecretStageScope>",
|
|
62
|
+
value: value
|
|
63
|
+
});
|
|
64
|
+
}).every(flag => flag)].every(flag => flag);
|
|
65
|
+
const __is = input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
|
|
66
|
+
let errors;
|
|
67
|
+
let _report;
|
|
68
|
+
const __validate = input => {
|
|
69
|
+
if (false === __is(input)) {
|
|
70
|
+
errors = [];
|
|
71
|
+
_report = _validateReport_1._validateReport(errors);
|
|
72
|
+
((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input && false === Array.isArray(input) || _report(true, {
|
|
73
|
+
path: _path + "",
|
|
74
|
+
expected: "WranglerPackageManifest",
|
|
75
|
+
value: input
|
|
76
|
+
})) && _vo0(input, _path + "", true) || _report(true, {
|
|
77
|
+
path: _path + "",
|
|
78
|
+
expected: "WranglerPackageManifest",
|
|
79
|
+
value: input
|
|
80
|
+
}))(input, "$input", true);
|
|
81
|
+
const success = 0 === errors.length;
|
|
82
|
+
return success ? {
|
|
83
|
+
success,
|
|
84
|
+
data: input
|
|
85
|
+
} : {
|
|
86
|
+
success,
|
|
87
|
+
errors,
|
|
88
|
+
data: input
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
success: true,
|
|
93
|
+
data: input
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
return input => __validate(JSON.parse(input));
|
|
97
|
+
})();
|
|
98
|
+
/** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
|
|
99
|
+
export function readDeclaredSecretNames(cwd) {
|
|
100
|
+
const path = join(cwd, '.dev.vars.example');
|
|
101
|
+
return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
|
|
102
|
+
}
|
|
103
|
+
/** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
|
|
104
|
+
export function readSecretStageMap(cwd) {
|
|
105
|
+
const path = join(cwd, 'package.json');
|
|
106
|
+
if (!existsSync(path))
|
|
107
|
+
return {};
|
|
108
|
+
const result = parseJsonFileText(path, readFileSync(path, 'utf8'), validateWranglerManifest);
|
|
109
|
+
if (!result.success) {
|
|
110
|
+
throw new Error(`${path} declares an invalid smoo.wrangler block: ${formatValidationErrors(result.errors)}`);
|
|
111
|
+
}
|
|
112
|
+
return result.data.smoo?.wrangler?.secretStages ?? {};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Splits the declared secrets by whether `stage` is in scope for each.
|
|
116
|
+
*
|
|
117
|
+
* A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
|
|
118
|
+
* real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
|
|
119
|
+
* nothing while the real secret, still absent from the map, reaches every stage.
|
|
120
|
+
*/
|
|
121
|
+
export function planStageSecrets(declared, stage, scopes) {
|
|
122
|
+
const declaredNames = new Set(declared);
|
|
123
|
+
const undeclared = Object.keys(scopes).filter((name) => !declaredNames.has(name));
|
|
124
|
+
if (undeclared.length > 0) {
|
|
125
|
+
throw new Error(`smoo.wrangler.secretStages scopes ${undeclared.join(', ')}, which .dev.vars.example does not declare. ` +
|
|
126
|
+
'A scope on a name no secret has leaves the secret it was meant for unscoped, so that secret reaches every stage.');
|
|
127
|
+
}
|
|
128
|
+
// Every `prN` stage answers to one written token: a scope cannot name pull requests in advance.
|
|
129
|
+
const scope = isPullRequestStage(stage) ? 'preview' : stage;
|
|
130
|
+
const required = [];
|
|
131
|
+
const withheld = [];
|
|
132
|
+
for (const name of declared) {
|
|
133
|
+
const declaredScope = scopes[name];
|
|
134
|
+
if (declaredScope === undefined || declaredScope.includes(scope)) {
|
|
135
|
+
required.push(name);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
withheld.push(name);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { stage, required, withheld, scopes };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Why this deploy must not proceed, or nothing when it may.
|
|
145
|
+
*
|
|
146
|
+
* Two refusals, reported together so one run names every problem:
|
|
147
|
+
*
|
|
148
|
+
* - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
|
|
149
|
+
* that never mentions a secret leaves whatever the Worker already holds — silence that reads as
|
|
150
|
+
* success while a secret introduced after the first deploy never arrives, and the code that
|
|
151
|
+
* needs it fails at runtime instead of here.
|
|
152
|
+
* - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
|
|
153
|
+
* remove it, so the scope would be nominal rather than enforced until someone deletes it.
|
|
154
|
+
*
|
|
155
|
+
* A value is never read, never formatted, and never named beyond its key.
|
|
156
|
+
*/
|
|
157
|
+
export function stageSecretRefusal(plan, exported, held, workerName) {
|
|
158
|
+
const unavailable = plan.required.filter((name) => !exported.has(name) && !held.has(name));
|
|
159
|
+
const installed = plan.withheld.filter((name) => held.has(name));
|
|
160
|
+
if (unavailable.length === 0 && installed.length === 0)
|
|
161
|
+
return undefined;
|
|
162
|
+
const lines = [`Refusing to deploy ${workerName} to ${plan.stage}.`];
|
|
163
|
+
if (unavailable.length > 0) {
|
|
164
|
+
lines.push(`Stage ${plan.stage} requires these secrets and no value exists for them, neither in this environment nor on the Worker:`, ...unavailable.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`), 'Export each one in the deploying shell before the deploy. Once the Worker exists,', `\`wrangler secret put <NAME> --name ${workerName}\` supplies it too.`);
|
|
165
|
+
}
|
|
166
|
+
if (installed.length > 0) {
|
|
167
|
+
lines.push(`The Worker holds these secrets, which smoo.wrangler.secretStages keeps out of ${plan.stage}:`, ...installed.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`), `Delete each one with \`wrangler secret delete <NAME> --name ${workerName}\`. A deploy of this stage`, 'never sends them, so leaving them installed would keep the scope nominal.');
|
|
168
|
+
}
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
function scopeDescription(name, scopes) {
|
|
172
|
+
const scope = scopes[name];
|
|
173
|
+
if (scope === undefined)
|
|
174
|
+
return 'unscoped, so every stage requires it';
|
|
175
|
+
if (scope.length === 0)
|
|
176
|
+
return 'scoped to no stage (local development only)';
|
|
177
|
+
return `scoped to ${scope.join(', ')}`;
|
|
178
|
+
}
|
|
@@ -354,7 +354,15 @@
|
|
|
354
354
|
export GOFLAGS="''${GOFLAGS:--trimpath}"
|
|
355
355
|
unset GOROOT
|
|
356
356
|
bun "$DEVENV_ROOT/setup-environment.ts" || exit $?
|
|
357
|
-
|
|
357
|
+
# One socket dir per Nx workspace. DEVENV_RUNTIME is keyed to the devenv
|
|
358
|
+
# ROOT, so every workspace sharing one devenv - a sibling repository, a
|
|
359
|
+
# copy-on-write clone, a scratch workspace created inside this shell -
|
|
360
|
+
# would land on one socket. The first daemon to claim it then refuses
|
|
361
|
+
# every message from the others ("received a message from a different
|
|
362
|
+
# workspace"), which reads as a hung Nx in a workspace that did nothing
|
|
363
|
+
# wrong. Nx's own diagnostic names this exact cause.
|
|
364
|
+
nx_workspace_root="$(cd "$DEVENV_ROOT/../.." >/dev/null 2>&1 && pwd || printf '%s' "$PWD")"
|
|
365
|
+
export NX_SOCKET_DIR="$DEVENV_RUNTIME/nx-$(printf '%s' "$nx_workspace_root" | cksum | cut -d' ' -f1)"
|
|
358
366
|
mkdir -p "$NX_SOCKET_DIR"
|
|
359
367
|
${lib.optionalString pkgs.stdenv.isDarwin ''
|
|
360
368
|
unset CC CXX
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# macOS-only Linux compile gate.
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
2
|
+
# macOS-only Linux cross-compile gate. This hook is a cache PROBE and nothing
|
|
3
|
+
# else: it reads the nx cache and never compiles. A hit means a real
|
|
4
|
+
# `cargo clippy --target x86_64-unknown-linux-gnu` already passed for exactly
|
|
5
|
+
# this tree, so the push is safe with no toolchain present. Anything else
|
|
6
|
+
# refuses the push and names the one command that fixes it.
|
|
7
|
+
#
|
|
8
|
+
# Why probe-only. The cross-clippy needs the linux-cross C toolchain, which
|
|
9
|
+
# lives in a devenv profile. A hook that entered that profile spent minutes
|
|
10
|
+
# compiling on a push the user expected to take a second, and it did so in an
|
|
11
|
+
# environment they never asked for. Pushing is not the place to discover that
|
|
12
|
+
# the tree has not been compiled for Linux; `bun run check:linux` is.
|
|
13
|
+
#
|
|
14
|
+
# CC_x86_64_unknown_linux_gnu is unset for the probe deliberately. The target's
|
|
15
|
+
# own guard reads it to decide whether the toolchain is present, so leaving it
|
|
16
|
+
# set would let a push made from inside an already-entered linux-cross shell
|
|
17
|
+
# fall through into a real multi-minute compile. It is not a declared input of
|
|
18
|
+
# the target, so unsetting it cannot change the task hash — a warm entry still
|
|
19
|
+
# hits.
|
|
20
|
+
#
|
|
21
|
+
# Everything in tooling depends only on devenv.nix packages, so this hook also
|
|
22
|
+
# works with no devenv on PATH. It must never pretend a check ran that did not.
|
|
23
23
|
|
|
24
24
|
cd "$(git rev-parse --show-toplevel)"
|
|
25
25
|
TOOLING="$PWD/tooling"
|
|
@@ -31,17 +31,18 @@ case "$(uname -s)" in
|
|
|
31
31
|
*) exit 0 ;;
|
|
32
32
|
esac
|
|
33
33
|
|
|
34
|
-
if nx run-many -t cargo-lint-cross; then
|
|
34
|
+
if env -u CC_x86_64_unknown_linux_gnu nx run-many -t cargo-lint-cross --output-style=static; then
|
|
35
35
|
exit 0
|
|
36
36
|
fi
|
|
37
37
|
|
|
38
|
-
if has_nix_devenv; then
|
|
39
|
-
exec bun run check:linux
|
|
40
|
-
fi
|
|
41
|
-
|
|
42
38
|
cat >&2 <<'EOF'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
|
|
40
|
+
pre-push: the Linux cross-compile check is NOT cached for this tree, so this
|
|
41
|
+
push would ship code that has never been compiled for Linux. This hook only
|
|
42
|
+
reads the cache; it does not build. The "needs the linux-cross C toolchain"
|
|
43
|
+
line above is the probe refusing to compile, not a broken toolchain.
|
|
44
|
+
|
|
45
|
+
Run: bun run check:linux
|
|
46
|
+
Wait for it to pass, then push again.
|
|
46
47
|
EOF
|
|
47
48
|
exit 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smoothbricks/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.18",
|
|
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.12",
|
|
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
|
@@ -532,23 +532,46 @@ function buildProgram(): Command {
|
|
|
532
532
|
.description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
|
|
533
533
|
secrets
|
|
534
534
|
.command('status')
|
|
535
|
-
.description(
|
|
536
|
-
|
|
537
|
-
|
|
535
|
+
.description(
|
|
536
|
+
'Show every declared secret with the scopes holding it, and refuse when a workflow passes one none has',
|
|
537
|
+
)
|
|
538
|
+
.option(
|
|
539
|
+
'-R, --repo <owner/name|remote>',
|
|
540
|
+
"repository or remote name; defaults to the current branch's upstream remote",
|
|
541
|
+
)
|
|
542
|
+
.option(
|
|
543
|
+
'--env <environment>',
|
|
544
|
+
"also read this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
545
|
+
)
|
|
546
|
+
.action(async (options: { repo?: string; env?: string }) => {
|
|
538
547
|
process.exitCode = secretsStatus(await findRepoRoot(), options);
|
|
539
548
|
});
|
|
540
549
|
secrets
|
|
541
|
-
.command('set
|
|
542
|
-
.description('Set
|
|
543
|
-
.option(
|
|
544
|
-
|
|
545
|
-
|
|
550
|
+
.command('set [name]')
|
|
551
|
+
.description('Set secrets from pasted values; with no name, prompts for every secret the target scope still lacks')
|
|
552
|
+
.option(
|
|
553
|
+
'-R, --repo <owner/name|remote>',
|
|
554
|
+
"repository or remote name; defaults to the current branch's upstream remote",
|
|
555
|
+
)
|
|
556
|
+
.option(
|
|
557
|
+
'--env <environment>',
|
|
558
|
+
"write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
559
|
+
)
|
|
560
|
+
.action(async (name: string | undefined, options: { repo?: string; env?: string }) => {
|
|
561
|
+
process.exitCode = await secretsSet(await findRepoRoot(), name, options);
|
|
546
562
|
});
|
|
547
563
|
secrets
|
|
548
564
|
.command('sync')
|
|
549
565
|
.description('Push every secret smoo.secrets can fetch locally to the repository')
|
|
550
|
-
.option(
|
|
551
|
-
|
|
566
|
+
.option(
|
|
567
|
+
'-R, --repo <owner/name|remote>',
|
|
568
|
+
"repository or remote name; defaults to the current branch's upstream remote",
|
|
569
|
+
)
|
|
570
|
+
.option(
|
|
571
|
+
'--env <environment>',
|
|
572
|
+
"write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
573
|
+
)
|
|
574
|
+
.action(async (options: { repo?: string; env?: string }) => {
|
|
552
575
|
process.exitCode = await secretsSync(await findRepoRoot(), options);
|
|
553
576
|
});
|
|
554
577
|
|
|
@@ -7,6 +7,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { format } from 'prettier';
|
|
10
|
+
import typia from 'typia';
|
|
10
11
|
import type { PackageCargoGitOrigin } from '../../lib/json.js';
|
|
11
12
|
import {
|
|
12
13
|
type CiWorkflowDefinitionOptions,
|
|
@@ -898,3 +899,163 @@ describe('renderCiWorkflowYaml with deploy configuration', () => {
|
|
|
898
899
|
expect(e2eJob).not.toContain('CARGO_REGISTRIES_EXAMPLE_TOKEN');
|
|
899
900
|
});
|
|
900
901
|
});
|
|
902
|
+
|
|
903
|
+
describe('renderCiWorkflowYaml with cross-built test archives', () => {
|
|
904
|
+
const darwin = {
|
|
905
|
+
triple: 'aarch64-apple-darwin',
|
|
906
|
+
path: 'target/nextest/archive-aarch64-apple-darwin.tar.zst',
|
|
907
|
+
};
|
|
908
|
+
const declared = options({
|
|
909
|
+
runsOn: [...nixosRunsOn],
|
|
910
|
+
macosRunsOn: ['macos-arm64', 'self-hosted'],
|
|
911
|
+
crossTestArchives: [darwin],
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('renders nothing for a repository that declares no cross archives', () => {
|
|
915
|
+
const bare = renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn] }));
|
|
916
|
+
|
|
917
|
+
// An empty declaration is the same repository as an absent one: no step, no
|
|
918
|
+
// job, and above all no renumbering of the steps that were already there.
|
|
919
|
+
expect(renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn], crossTestArchives: [] }))).toBe(bare);
|
|
920
|
+
expect(bare).not.toContain('macos-cross-tests');
|
|
921
|
+
expect(bare).not.toContain('cross-target test archives');
|
|
922
|
+
expect(bare).not.toContain('cargo-cross-test');
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('builds and uploads each archive in Validate, then executes it in a job that needs Validate', () => {
|
|
926
|
+
const rendered = renderCiWorkflowYaml(declared);
|
|
927
|
+
const workflow = typia.assert<{
|
|
928
|
+
jobs: Record<string, { needs?: string; 'runs-on'?: unknown; 'timeout-minutes'?: number; steps: unknown[] }>;
|
|
929
|
+
}>(Bun.YAML.parse(rendered));
|
|
930
|
+
const validate = workflow.jobs.main;
|
|
931
|
+
const execute = workflow.jobs['macos-cross-tests'];
|
|
932
|
+
|
|
933
|
+
expect(execute?.needs).toBe('main');
|
|
934
|
+
expect(execute?.['runs-on']).toEqual(['macos-arm64', 'self-hosted']);
|
|
935
|
+
expect(execute?.['timeout-minutes']).toBe(30);
|
|
936
|
+
// The Linux job COMPILES the darwin binaries; that is what proves the cross
|
|
937
|
+
// build works at all.
|
|
938
|
+
expect(rendered).toContain(
|
|
939
|
+
'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-aarch64-apple-darwin"',
|
|
940
|
+
);
|
|
941
|
+
expect(JSON.stringify(validate?.steps)).toContain('cross-test-archives-${{ github.run_id }}');
|
|
942
|
+
// ...and the macOS job only RUNS them: every step is checkout, the shell,
|
|
943
|
+
// the download, the archive run, or the cache save. Nothing invokes cargo,
|
|
944
|
+
// a toolchain install, or an SDK.
|
|
945
|
+
const executeSteps = execute?.steps ?? [];
|
|
946
|
+
expect(executeSteps).toContainEqual({
|
|
947
|
+
name: '🧪 Cross-Target Unit Tests',
|
|
948
|
+
run: 'smoo github-ci nx-run-many --targets "cargo-cross-test-aarch64-apple-darwin"',
|
|
949
|
+
});
|
|
950
|
+
const executeText = JSON.stringify(executeSteps);
|
|
951
|
+
expect(executeText).toContain('cross-test-archives-${{ github.run_id }}');
|
|
952
|
+
expect(executeText).toContain('actions/download-artifact');
|
|
953
|
+
expect(executeText).not.toContain('cargo ');
|
|
954
|
+
expect(executeText).not.toContain('rustup');
|
|
955
|
+
expect(executeText).not.toContain('cargo-cross-test-archive');
|
|
956
|
+
expect(executeText).not.toContain('--target build');
|
|
957
|
+
expect(executeText).not.toContain('SDK');
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('reuses the declared cross producer for the archive step, step-scoped', () => {
|
|
961
|
+
const rendered = renderCiWorkflowYaml(
|
|
962
|
+
options({
|
|
963
|
+
...declared,
|
|
964
|
+
platformProducer: {
|
|
965
|
+
kind: 'linux-cross',
|
|
966
|
+
preflight: 'sh scripts/prepare-macos-sdk.sh',
|
|
967
|
+
env: { ACME_CROSS: '1', SDKROOT: '${{ runner.temp }}/apple-sdk/MacOSX.sdk' },
|
|
968
|
+
},
|
|
969
|
+
}),
|
|
970
|
+
);
|
|
971
|
+
const validate = rendered.slice(0, rendered.indexOf(' macos-cross-tests:'));
|
|
972
|
+
|
|
973
|
+
expect(validate).toContain('- name: Check cross-platform toolchain prerequisites');
|
|
974
|
+
expect(validate).toContain(' working-directory: .\n');
|
|
975
|
+
expect(validate).toContain(' set -euo pipefail\n sh scripts/prepare-macos-sdk.sh');
|
|
976
|
+
// Step-scoped, so Validate's host builds stay host builds: the pair appears
|
|
977
|
+
// once per cross step and never in the job's own env block.
|
|
978
|
+
expect(validate.match(/ACME_CROSS: "1"/g)).toHaveLength(2);
|
|
979
|
+
const jobEnv = validate.slice(validate.indexOf(' env:'), validate.indexOf(' steps:'));
|
|
980
|
+
expect(jobEnv).not.toContain('ACME_CROSS');
|
|
981
|
+
expect(jobEnv).not.toContain('SDKROOT');
|
|
982
|
+
// A declared producer with no preflight is a mechanism error at render time.
|
|
983
|
+
expect(() =>
|
|
984
|
+
renderCiWorkflowYaml(options({ ...declared, platformProducer: { kind: 'linux-cross', preflight: ' ' } })),
|
|
985
|
+
).toThrow('nonempty toolchain preflight');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('numbers the execute job download before the run and keeps the cleanup anchor after both', () => {
|
|
989
|
+
const execute = crossTestJob(renderCiWorkflowYaml(declared));
|
|
990
|
+
|
|
991
|
+
// Checkout 2, setup-devenv 3, download 4, run 5, cleanup 6.
|
|
992
|
+
expect(execute).toContain('# Step 4\n - name: 📥 Download cross-target test archives');
|
|
993
|
+
expect(execute).toContain('# Step 5\n - name: 🧪 Cross-Target Unit Tests');
|
|
994
|
+
expect(execute).toContain('# Step 6');
|
|
995
|
+
expect(execute).toContain('uses: ./.github/actions/save-nix-devenv');
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('builds a non-darwin triple without an execution job it has no runner for', () => {
|
|
999
|
+
const rendered = renderCiWorkflowYaml(
|
|
1000
|
+
options({
|
|
1001
|
+
crossTestArchives: [
|
|
1002
|
+
{ triple: 'x86_64-unknown-linux-musl', path: 'target/nextest/archive-x86_64-unknown-linux-musl.tar.zst' },
|
|
1003
|
+
],
|
|
1004
|
+
}),
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
expect(rendered).toContain(
|
|
1008
|
+
'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-x86_64-unknown-linux-musl"',
|
|
1009
|
+
);
|
|
1010
|
+
expect(rendered).not.toContain('macos-cross-tests');
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('downloads a nested cargo workspace archive back to the directory its target writes', () => {
|
|
1014
|
+
const execute = crossTestJob(
|
|
1015
|
+
renderCiWorkflowYaml(
|
|
1016
|
+
options({
|
|
1017
|
+
crossTestArchives: [{ ...darwin, path: `packages/ferris/${darwin.path}` }],
|
|
1018
|
+
}),
|
|
1019
|
+
),
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
expect(execute).toContain('path: packages/ferris/target/nextest');
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('refuses declarations the Nx graph could not have produced, at render time', () => {
|
|
1026
|
+
expect(() =>
|
|
1027
|
+
renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: 'target/nextest/archive.tar.zst' }] })),
|
|
1028
|
+
).toThrow('must be a repository-relative target/nextest/archive-aarch64-apple-darwin.tar.zst');
|
|
1029
|
+
expect(() =>
|
|
1030
|
+
renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: `../${darwin.path}` }] })),
|
|
1031
|
+
).toThrow('must stay inside the repository');
|
|
1032
|
+
expect(() =>
|
|
1033
|
+
renderCiWorkflowYaml(
|
|
1034
|
+
options({ crossTestArchives: [{ triple: 'Bad Triple; rm -rf /', path: 'target/nextest/x.tar.zst' }] }),
|
|
1035
|
+
),
|
|
1036
|
+
).toThrow('must be a target triple');
|
|
1037
|
+
// Two cargo workspaces cannot share one artifact: upload-artifact roots it
|
|
1038
|
+
// at the least common ancestor, so the restored paths would be wrong for
|
|
1039
|
+
// both. Refuse here rather than on the runner.
|
|
1040
|
+
expect(() =>
|
|
1041
|
+
renderCiWorkflowYaml(
|
|
1042
|
+
options({
|
|
1043
|
+
crossTestArchives: [
|
|
1044
|
+
darwin,
|
|
1045
|
+
{ triple: 'x86_64-apple-darwin', path: `packages/ferris/${cargoArchive('x86_64-apple-darwin')}` },
|
|
1046
|
+
],
|
|
1047
|
+
}),
|
|
1048
|
+
),
|
|
1049
|
+
).toThrow('must share one directory');
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
function crossTestJob(rendered: string): string {
|
|
1054
|
+
const start = rendered.indexOf(' macos-cross-tests:');
|
|
1055
|
+
expect(start).toBeGreaterThan(-1);
|
|
1056
|
+
return rendered.slice(start);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function cargoArchive(triple: string): string {
|
|
1060
|
+
return `target/nextest/archive-${triple}.tar.zst`;
|
|
1061
|
+
}
|