@smoothbricks/cli 0.11.16 → 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 +65 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +47 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- 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 +214 -8
- 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/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +58 -20
- 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 +84 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +375 -0
- package/dist/secrets/index.d.ts +103 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +255 -0
- 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 +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +89 -78
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- 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 +69 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +294 -8
- package/src/monorepo/managed-files.test.ts +28 -1
- package/src/monorepo/managed-files.ts +56 -2
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/monorepo/publish-workflow.ts +65 -19
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/__tests__/private-npm-status.test.ts +2 -2
- 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 +412 -0
- package/src/secrets/index.test.ts +209 -0
- package/src/secrets/index.ts +287 -0
- 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 +448 -23
- package/src/wrangler/deploy-stage.ts +142 -83
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
- package/src/wrangler/stage-secrets.ts +146 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as _validateReport_1 from "typia/lib/internal/_validateReport";
|
|
2
|
+
// What is live right now, as opposed to what we last uploaded.
|
|
3
|
+
//
|
|
4
|
+
// `wrangler versions deploy` returns when Cloudflare ACCEPTS the traffic shift, not when the edge
|
|
5
|
+
// serves it. A deploy that returns is therefore not yet a deploy that answers: the site that signs
|
|
6
|
+
// in to its stage's backend measured the previous backend version's response about 20 s after the
|
|
7
|
+
// backend's deploy step had already gone green. Everything here exists to turn "we asked" into
|
|
8
|
+
// "we observed".
|
|
9
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import typia from 'typia';
|
|
12
|
+
const isUnknownRecord = (() => {
|
|
13
|
+
const _io0 = input => Object.keys(input).every(key => {
|
|
14
|
+
const value = input[key];
|
|
15
|
+
if (undefined === value)
|
|
16
|
+
return true;
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
return input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
|
|
20
|
+
})();
|
|
21
|
+
/**
|
|
22
|
+
* Every `{ id -> tag }` pair the payload carries, in document order.
|
|
23
|
+
*
|
|
24
|
+
* Wrangler nests versions differently per subcommand and per version of itself, spells the tag
|
|
25
|
+
* three ways (`annotations['workers/tag']`, a bare `tag`, `metadata.tag`) and the id two (`id`,
|
|
26
|
+
* `version_id`). One walk collects the relation once; both directions of the lookup are then a Map
|
|
27
|
+
* read rather than a second recursive scan that could disagree with the first.
|
|
28
|
+
*/
|
|
29
|
+
export function collectVersionTags(value) {
|
|
30
|
+
const pairs = new Map();
|
|
31
|
+
collectInto(value, pairs);
|
|
32
|
+
return pairs;
|
|
33
|
+
}
|
|
34
|
+
function collectInto(value, pairs) {
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
for (const entry of value)
|
|
37
|
+
collectInto(entry, pairs);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!isUnknownRecord(value))
|
|
41
|
+
return;
|
|
42
|
+
const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
|
|
43
|
+
const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
|
|
44
|
+
const annotationTag = annotations?.['workers/tag'];
|
|
45
|
+
const candidateTag = typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
|
|
46
|
+
const candidateId = typeof value.id === 'string' ? value.id : typeof value.version_id === 'string' ? value.version_id : undefined;
|
|
47
|
+
if (typeof candidateTag === 'string' && candidateId !== undefined && !pairs.has(candidateId)) {
|
|
48
|
+
pairs.set(candidateId, candidateTag);
|
|
49
|
+
}
|
|
50
|
+
for (const nested of Object.values(value))
|
|
51
|
+
collectInto(nested, pairs);
|
|
52
|
+
}
|
|
53
|
+
/** The version id whose tag is `tag`, or null. */
|
|
54
|
+
export function findVersionIdByTag(value, tag) {
|
|
55
|
+
for (const [id, candidate] of collectVersionTags(value)) {
|
|
56
|
+
if (candidate === tag)
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The current deployment's traffic split, or null when the payload names no deployment at all.
|
|
63
|
+
*
|
|
64
|
+
* "Nothing is deployed" and "two versions share the traffic" are different facts with different
|
|
65
|
+
* remedies, and a caller that collapses them cannot tell an operator which one it hit — so the
|
|
66
|
+
* split is returned whole and both questions are answered from it.
|
|
67
|
+
*/
|
|
68
|
+
export function currentDeploymentVersions(value) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
for (const entry of value) {
|
|
71
|
+
const found = currentDeploymentVersions(entry);
|
|
72
|
+
if (found)
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (!isUnknownRecord(value))
|
|
78
|
+
return null;
|
|
79
|
+
if (Array.isArray(value.versions)) {
|
|
80
|
+
return value.versions.map((version) => {
|
|
81
|
+
if (!isUnknownRecord(version))
|
|
82
|
+
return { versionId: null, percentage: 0 };
|
|
83
|
+
const id = version.version_id ?? version.id;
|
|
84
|
+
return { versionId: typeof id === 'string' ? id : null, percentage: Number(version.percentage) };
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
for (const nested of Object.values(value)) {
|
|
88
|
+
const found = currentDeploymentVersions(nested);
|
|
89
|
+
if (found)
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The one version serving 100% of traffic, or null when there is none or the deployment is split.
|
|
96
|
+
*
|
|
97
|
+
* A split is not "close enough": with two versions live, "is our version live" has no single
|
|
98
|
+
* answer, and answering it optimistically is how a deploy reports success while part of the
|
|
99
|
+
* traffic still reaches the old code.
|
|
100
|
+
*/
|
|
101
|
+
export function currentDeploymentVersionId(value) {
|
|
102
|
+
const shares = currentDeploymentVersions(value);
|
|
103
|
+
if (shares?.length !== 1)
|
|
104
|
+
return null;
|
|
105
|
+
const [only] = shares;
|
|
106
|
+
return only.percentage === 100 ? only.versionId : null;
|
|
107
|
+
}
|
|
108
|
+
/** The version serving all traffic for this worker, with the tag it was uploaded under. */
|
|
109
|
+
export async function readLiveVersion(probe) {
|
|
110
|
+
const deployments = await probe.deployments();
|
|
111
|
+
const shares = currentDeploymentVersions(deployments);
|
|
112
|
+
const versionId = currentDeploymentVersionId(deployments);
|
|
113
|
+
if (!versionId) {
|
|
114
|
+
return shares && shares.length > 0
|
|
115
|
+
? {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: {
|
|
118
|
+
kind: 'split-traffic',
|
|
119
|
+
message: 'no single worker version is serving 100% of traffic; refusing to name one as live',
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: { ok: false, error: { kind: 'never-deployed', message: 'this worker has no active deployment' } };
|
|
123
|
+
}
|
|
124
|
+
const tag = collectVersionTags(await probe.versions()).get(versionId) ?? null;
|
|
125
|
+
return { ok: true, value: { versionId, tag } };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* How long a deploy waits for the version it just activated to become the one being served.
|
|
129
|
+
*
|
|
130
|
+
* The bound is the point: an unbounded loop turns a stuck propagation into a hung CI job nobody
|
|
131
|
+
* reads, and a fixed sleep turns it into a green job that lied. Both are worse than a red job
|
|
132
|
+
* naming what it expected and what it saw.
|
|
133
|
+
*/
|
|
134
|
+
export const LIVE_VERSION_WAIT_BUDGET_MS = 120_000;
|
|
135
|
+
/** Cloudflare's control plane converges in seconds; polling faster than this only burns API quota. */
|
|
136
|
+
export const LIVE_VERSION_POLL_INTERVAL_MS = 2_000;
|
|
137
|
+
/**
|
|
138
|
+
* Runs `attempt` until it succeeds or the budget expires, and reports the LAST thing it saw.
|
|
139
|
+
*
|
|
140
|
+
* Both waits below need the same deadline arithmetic and the same "what did you see instead"
|
|
141
|
+
* record; sharing it keeps the two from drifting into disagreeing about when a wait is over.
|
|
142
|
+
*/
|
|
143
|
+
async function pollWithinBudget(attempt, options) {
|
|
144
|
+
const budgetMs = options.budgetMs ?? LIVE_VERSION_WAIT_BUDGET_MS;
|
|
145
|
+
const intervalMs = options.intervalMs ?? LIVE_VERSION_POLL_INTERVAL_MS;
|
|
146
|
+
const now = options.now ?? Date.now;
|
|
147
|
+
const sleep = options.sleep ?? sleepFor;
|
|
148
|
+
const deadline = now() + budgetMs;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const result = await attempt();
|
|
151
|
+
if (result.done)
|
|
152
|
+
return { ok: true, value: result.value };
|
|
153
|
+
if (now() >= deadline)
|
|
154
|
+
return { ok: false, error: { observed: result.observed, waitedMs: budgetMs } };
|
|
155
|
+
await sleep(intervalMs);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function sleepFor(ms) {
|
|
159
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
160
|
+
setTimeout(resolve, ms);
|
|
161
|
+
return promise;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Polls until the worker's live version carries `expectedTag`, or the budget runs out.
|
|
165
|
+
*
|
|
166
|
+
* The tag is the match, not the version id: the tag IS the desired-state identity (`nx-<task
|
|
167
|
+
* hash>`), it is what both `wrangler deploy --tag` and `wrangler versions deploy --version-tag`
|
|
168
|
+
* were told to make live, and it is the one name the caller knows before the upload has produced
|
|
169
|
+
* a version id.
|
|
170
|
+
*
|
|
171
|
+
* Returns the failure rather than throwing it: a deploy that cannot be observed is an operational
|
|
172
|
+
* outcome the caller has to report, not a broken invariant.
|
|
173
|
+
*/
|
|
174
|
+
export async function awaitLiveVersion(probe, expectedTag, options = {}) {
|
|
175
|
+
const polled = await pollWithinBudget(async () => {
|
|
176
|
+
const live = await readLiveVersion(probe);
|
|
177
|
+
if (!live.ok)
|
|
178
|
+
return { done: false, observed: live.error.message };
|
|
179
|
+
const { tag, versionId } = live.value;
|
|
180
|
+
if (tag === expectedTag)
|
|
181
|
+
return { done: true, value: live.value };
|
|
182
|
+
return { done: false, observed: tag ? `${tag} (${versionId})` : `an untagged version (${versionId})` };
|
|
183
|
+
}, options);
|
|
184
|
+
if (polled.ok)
|
|
185
|
+
return polled;
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error: {
|
|
189
|
+
kind: 'not-observed',
|
|
190
|
+
expected: expectedTag,
|
|
191
|
+
observed: polled.error.observed,
|
|
192
|
+
message: `deployed version ${expectedTag} was not serving traffic after ` +
|
|
193
|
+
`${Math.round(polled.error.waitedMs / 1000)}s; live is ${polled.error.observed}`,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Polls a worker-served endpoint until it answers with `expectedTag`.
|
|
199
|
+
*
|
|
200
|
+
* The control plane agreeing is necessary and not sufficient — that gap is exactly what this
|
|
201
|
+
* change's predecessor measured — so a project that can prove the edge serves the new code says so
|
|
202
|
+
* with an endpoint echoing its own version tag (Workers read it from the version-metadata
|
|
203
|
+
* binding). The contract is deliberately one shape: the trimmed response body IS the tag.
|
|
204
|
+
*/
|
|
205
|
+
export async function awaitVersionEndpoint(url, expectedTag, options = {}) {
|
|
206
|
+
const request = options.fetch ?? fetch;
|
|
207
|
+
const polled = await pollWithinBudget(async () => {
|
|
208
|
+
const observed = await probeVersionEndpoint(request, url);
|
|
209
|
+
return observed === expectedTag ? { done: true, value: undefined } : { done: false, observed };
|
|
210
|
+
}, options);
|
|
211
|
+
if (polled.ok)
|
|
212
|
+
return polled;
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
error: {
|
|
216
|
+
kind: 'not-observed',
|
|
217
|
+
expected: expectedTag,
|
|
218
|
+
observed: polled.error.observed,
|
|
219
|
+
message: `${url} did not report version ${expectedTag} after ` +
|
|
220
|
+
`${Math.round(polled.error.waitedMs / 1000)}s; it reported ${polled.error.observed}`,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
async function probeVersionEndpoint(request, url) {
|
|
225
|
+
try {
|
|
226
|
+
const response = await request(url, { headers: { accept: 'text/plain' } });
|
|
227
|
+
const body = (await response.text()).trim();
|
|
228
|
+
return response.ok ? body : `HTTP ${response.status} ${body}`.trim();
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
// A worker mid-rollout refuses connections; that is a poll result, not a reason to abandon the
|
|
232
|
+
// wait. It only becomes the failure when it is still the answer at the deadline.
|
|
233
|
+
return error instanceof Error ? error.message : String(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* How long `smoo wrangler deployed-version` trusts its own last answer.
|
|
238
|
+
*
|
|
239
|
+
* This TTL is a convenience for operators and repeated local queries, NOT a correctness mechanism:
|
|
240
|
+
* nothing that decides whether to deploy may read it. The deploy's own liveness check always calls
|
|
241
|
+
* Cloudflare, because a cached "live already equals the desired tag" is precisely the belief a
|
|
242
|
+
* rollback falsifies, and acting on it would skip the deploy that repairs the rollback.
|
|
243
|
+
*/
|
|
244
|
+
export const LIVE_VERSION_CACHE_TTL_MS = 45_000;
|
|
245
|
+
const parseCachedLiveVersion = (() => {
|
|
246
|
+
const _io0 = input => (null === input.versionTag || "string" === typeof input.versionTag) && "string" === typeof input.versionId && "number" === typeof input.fetchedAt;
|
|
247
|
+
const _vo0 = (input, _path, _exceptionable = true) => [null === input.versionTag || "string" === typeof input.versionTag || _report(_exceptionable, {
|
|
248
|
+
path: _path + ".versionTag",
|
|
249
|
+
expected: "(null | string)",
|
|
250
|
+
value: input.versionTag
|
|
251
|
+
}), "string" === typeof input.versionId || _report(_exceptionable, {
|
|
252
|
+
path: _path + ".versionId",
|
|
253
|
+
expected: "string",
|
|
254
|
+
value: input.versionId
|
|
255
|
+
}), "number" === typeof input.fetchedAt || _report(_exceptionable, {
|
|
256
|
+
path: _path + ".fetchedAt",
|
|
257
|
+
expected: "number",
|
|
258
|
+
value: input.fetchedAt
|
|
259
|
+
})].every(flag => flag);
|
|
260
|
+
const __is = input => "object" === typeof input && null !== input && _io0(input);
|
|
261
|
+
let errors;
|
|
262
|
+
let _report;
|
|
263
|
+
const __validate = input => {
|
|
264
|
+
if (false === __is(input)) {
|
|
265
|
+
errors = [];
|
|
266
|
+
_report = _validateReport_1._validateReport(errors);
|
|
267
|
+
((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _report(true, {
|
|
268
|
+
path: _path + "",
|
|
269
|
+
expected: "CachedLiveVersion",
|
|
270
|
+
value: input
|
|
271
|
+
})) && _vo0(input, _path + "", true) || _report(true, {
|
|
272
|
+
path: _path + "",
|
|
273
|
+
expected: "CachedLiveVersion",
|
|
274
|
+
value: input
|
|
275
|
+
}))(input, "$input", true);
|
|
276
|
+
const success = 0 === errors.length;
|
|
277
|
+
return success ? {
|
|
278
|
+
success,
|
|
279
|
+
data: input
|
|
280
|
+
} : {
|
|
281
|
+
success,
|
|
282
|
+
errors,
|
|
283
|
+
data: input
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
success: true,
|
|
288
|
+
data: input
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
return input => __validate(JSON.parse(input));
|
|
292
|
+
})();
|
|
293
|
+
/**
|
|
294
|
+
* One file per key, so two deploys running in parallel cannot clobber each other's answer; a
|
|
295
|
+
* shared map would need a lock to say the same thing.
|
|
296
|
+
*/
|
|
297
|
+
export function liveVersionCachePath(cacheDirectory, key) {
|
|
298
|
+
const name = [key.accountId, key.workerName, key.stage].map(encodeURIComponent).join('_');
|
|
299
|
+
return join(cacheDirectory, 'smoo-live-version', `${name}.json`);
|
|
300
|
+
}
|
|
301
|
+
export async function readCachedLiveVersion(cacheDirectory, key, ttlMs, now = Date.now) {
|
|
302
|
+
let text;
|
|
303
|
+
try {
|
|
304
|
+
text = await readFile(liveVersionCachePath(cacheDirectory, key), 'utf8');
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const parsed = parseCachedLiveVersion(text);
|
|
310
|
+
if (!parsed.success)
|
|
311
|
+
return null;
|
|
312
|
+
return now() - parsed.data.fetchedAt < ttlMs ? parsed.data : null;
|
|
313
|
+
}
|
|
314
|
+
export async function writeCachedLiveVersion(cacheDirectory, key, entry) {
|
|
315
|
+
const path = liveVersionCachePath(cacheDirectory, key);
|
|
316
|
+
await mkdir(dirname(path), { recursive: true });
|
|
317
|
+
await writeFile(path, `${JSON.stringify(entry)}\n`);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Where the cache lives. Nx's workspace data directory already exists, is already gitignored, and
|
|
321
|
+
* is already wiped when the workspace is reset — three properties a hand-rolled directory would
|
|
322
|
+
* each have to re-earn, and one ($HOME) that would leak one checkout's answers into another's.
|
|
323
|
+
*/
|
|
324
|
+
export function liveVersionCacheDirectory(workspaceRoot, environment) {
|
|
325
|
+
return environment.NX_WORKSPACE_DATA_DIRECTORY ?? join(workspaceRoot, '.nx', 'workspace-data');
|
|
326
|
+
}
|
|
@@ -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
|