@smoothbricks/cli 0.11.16 → 0.11.17
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 +43 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -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.map +1 -1
- package/dist/monorepo/ci-workflow.js +15 -6
- 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/secrets/commands.d.ts +45 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +189 -0
- package/dist/secrets/index.d.ts +83 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +149 -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 +39 -54
- 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/package.json +2 -2
- package/src/cli.ts +46 -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 +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.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__/private-npm-status.test.ts +2 -2
- package/src/secrets/commands.ts +210 -0
- package/src/secrets/index.test.ts +92 -0
- package/src/secrets/index.ts +183 -0
- package/src/wrangler/deploy-stage.test.ts +190 -12
- package/src/wrangler/deploy-stage.ts +72 -45
- 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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../../src/secrets/commands.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAgBH,uFAAuF;AACvF,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAuB/D;AAED,mEAAmE;AACnE,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,GACvB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAWvD;AA8BD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAgC9E;AA8BD,0CAA0C;AAC1C,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAa1F;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAuB3F"}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `smoo secrets` — the operator side of the reconciliation in ./index.ts.
|
|
3
|
+
*
|
|
4
|
+
* Values are handed to `gh` over stdin, never through argv (a process list is
|
|
5
|
+
* world-readable) and never printed. Reading one from the terminal disables
|
|
6
|
+
* echo, so a pasted key does not end up in a scrollback buffer or a screen
|
|
7
|
+
* share.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
+
import { getWorkspacePackages } from '../lib/workspace.js';
|
|
11
|
+
import { fetchLocalSecret, localSecretCommandNames, reconcileSecrets, secretNameMapping, unsatisfiedSecrets, unwiredSecrets, workerSecretNames, workflowSecretNames, } from './index.js';
|
|
12
|
+
/** Repository secret names GitHub currently holds, or a refusal naming what failed. */
|
|
13
|
+
export function readRepositorySecrets(repo) {
|
|
14
|
+
const args = ['secret', 'list', '--json', 'name'];
|
|
15
|
+
if (repo)
|
|
16
|
+
args.push('--repo', repo);
|
|
17
|
+
const result = spawnSync('gh', args, { encoding: 'utf8' });
|
|
18
|
+
if (result.status !== 0) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
reason: `gh ${args.join(' ')} exited ${result.status ?? 'without status'}: ${result.stderr.trim()}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const parsed = JSON.parse(result.stdout);
|
|
25
|
+
if (!Array.isArray(parsed)) {
|
|
26
|
+
return { ok: false, reason: 'gh secret list did not return a list' };
|
|
27
|
+
}
|
|
28
|
+
const names = [];
|
|
29
|
+
for (const row of parsed) {
|
|
30
|
+
if (typeof row !== 'object' || row === null || !('name' in row))
|
|
31
|
+
continue;
|
|
32
|
+
// `in` narrows the property to unknown, so the typeof check below is the
|
|
33
|
+
// only thing that admits it — no assertion about gh's output shape.
|
|
34
|
+
const name = row.name;
|
|
35
|
+
if (typeof name === 'string')
|
|
36
|
+
names.push(name);
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, names: names.sort((left, right) => left.localeCompare(right)) };
|
|
39
|
+
}
|
|
40
|
+
/** Write one secret, value over stdin so it never reaches argv. */
|
|
41
|
+
export async function writeRepositorySecret(name, value, repo) {
|
|
42
|
+
const args = ['secret', 'set', name];
|
|
43
|
+
if (repo)
|
|
44
|
+
args.push('--repo', repo);
|
|
45
|
+
const child = spawn('gh', args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
46
|
+
child.stdin.end(value);
|
|
47
|
+
const status = await new Promise((resolvePromise) => {
|
|
48
|
+
child.on('close', (code) => resolvePromise(code));
|
|
49
|
+
});
|
|
50
|
+
return status === 0
|
|
51
|
+
? { ok: true }
|
|
52
|
+
: { ok: false, reason: `gh ${args.join(' ')} exited ${status ?? 'without status'}` };
|
|
53
|
+
}
|
|
54
|
+
function collectSources(root, repositorySecrets) {
|
|
55
|
+
const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
|
|
56
|
+
const workerSecrets = workerSecretNames(root, workspaceDirs);
|
|
57
|
+
const workflowSecrets = workflowSecretNames(root);
|
|
58
|
+
const localCommands = localSecretCommandNames(root);
|
|
59
|
+
const envNames = [
|
|
60
|
+
...new Set([...Object.values(workerSecrets).flatMap((names) => [...names]), ...workflowSecrets, ...localCommands]),
|
|
61
|
+
];
|
|
62
|
+
return {
|
|
63
|
+
workerSecrets,
|
|
64
|
+
workflowSecrets,
|
|
65
|
+
secretNames: secretNameMapping(root, envNames),
|
|
66
|
+
localCommands,
|
|
67
|
+
repositorySecrets,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function describe(row) {
|
|
71
|
+
const where = row.declaredByWorkers.length > 0 ? row.declaredByWorkers.join(', ') : '—';
|
|
72
|
+
return [
|
|
73
|
+
row.onRepository ? 'set ' : 'ABSENT',
|
|
74
|
+
row.name.padEnd(32),
|
|
75
|
+
row.suppliedByWorkflow ? 'workflow' : ' ',
|
|
76
|
+
row.fetchableLocally ? 'local' : ' ',
|
|
77
|
+
where,
|
|
78
|
+
].join(' ');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Print every known secret with its sources. Exit code 1 when a workflow
|
|
82
|
+
* promises a value the repository does not hold — that combination is the one
|
|
83
|
+
* that fails a deploy, and it fails talking about the value rather than the
|
|
84
|
+
* missing secret.
|
|
85
|
+
*/
|
|
86
|
+
export function secretsStatus(root, options) {
|
|
87
|
+
const repositorySecrets = readRepositorySecrets(options.repo);
|
|
88
|
+
if (!repositorySecrets.ok) {
|
|
89
|
+
console.error(repositorySecrets.reason);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const rows = reconcileSecrets(collectSources(root, repositorySecrets.names));
|
|
93
|
+
console.log('state name workflow local declared by');
|
|
94
|
+
for (const row of rows)
|
|
95
|
+
console.log(describe(row));
|
|
96
|
+
const unsatisfied = unsatisfiedSecrets(rows);
|
|
97
|
+
const unwired = unwiredSecrets(rows);
|
|
98
|
+
if (unwired.length > 0) {
|
|
99
|
+
console.log('');
|
|
100
|
+
for (const row of unwired) {
|
|
101
|
+
console.log(`note: ${row.name} is declared by ${row.declaredByWorkers.join(', ')} but no managed workflow passes it; ` +
|
|
102
|
+
'declare it in smoo.github.deploySecrets to have CI supply it.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (unsatisfied.length > 0) {
|
|
106
|
+
console.log('');
|
|
107
|
+
for (const row of unsatisfied) {
|
|
108
|
+
console.error(`missing: ${row.name} — a workflow passes secrets.${row.name} and the repository has no value.`);
|
|
109
|
+
console.error(` set it with: smoo secrets set ${row.name}${options.repo ? ` --repo ${options.repo}` : ''}`);
|
|
110
|
+
}
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
/** Read a value with echo disabled when stdin is a terminal; otherwise read piped input. */
|
|
116
|
+
async function readSecretValue(prompt) {
|
|
117
|
+
if (!process.stdin.isTTY) {
|
|
118
|
+
const chunks = [];
|
|
119
|
+
for await (const chunk of process.stdin)
|
|
120
|
+
chunks.push(Buffer.from(chunk));
|
|
121
|
+
return Buffer.concat(chunks).toString('utf8').replace(/\n$/, '');
|
|
122
|
+
}
|
|
123
|
+
process.stderr.write(prompt);
|
|
124
|
+
process.stdin.setRawMode(true);
|
|
125
|
+
let value = '';
|
|
126
|
+
try {
|
|
127
|
+
for await (const chunk of process.stdin) {
|
|
128
|
+
const text = Buffer.from(chunk).toString('utf8');
|
|
129
|
+
if (text === '\r' || text === '\n' || text === '\u0004')
|
|
130
|
+
break;
|
|
131
|
+
if (text === '\u0003')
|
|
132
|
+
throw new Error('cancelled');
|
|
133
|
+
if (text === '\u007f') {
|
|
134
|
+
value = value.slice(0, -1);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
value += text;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
process.stdin.setRawMode(false);
|
|
142
|
+
process.stderr.write('\n');
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/** Set one secret from a pasted value. */
|
|
147
|
+
export async function secretsSet(name, options) {
|
|
148
|
+
const value = await readSecretValue(`Value for ${name} (not echoed): `);
|
|
149
|
+
if (value.length === 0) {
|
|
150
|
+
console.error(`${name}: refusing to set an empty value.`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
const written = await writeRepositorySecret(name, value, options.repo);
|
|
154
|
+
if (!written.ok) {
|
|
155
|
+
console.error(written.reason);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
console.log(`set ${name}`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Push every locally fetchable secret to the repository. Only names
|
|
163
|
+
* `smoo.secrets` declares a command for: everything else has no source here
|
|
164
|
+
* and must be pasted with `smoo secrets set`.
|
|
165
|
+
*/
|
|
166
|
+
export async function secretsSync(root, options) {
|
|
167
|
+
const names = localSecretCommandNames(root);
|
|
168
|
+
if (names.length === 0) {
|
|
169
|
+
console.error('smoo.secrets declares no fetch commands; nothing to sync.');
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
let failed = 0;
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
const fetched = fetchLocalSecret(root, name);
|
|
175
|
+
if (!fetched.ok) {
|
|
176
|
+
console.error(`skip ${name}: ${fetched.reason}`);
|
|
177
|
+
failed += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const written = await writeRepositorySecret(name, fetched.value, options.repo);
|
|
181
|
+
if (!written.ok) {
|
|
182
|
+
console.error(`fail ${name}: ${written.reason}`);
|
|
183
|
+
failed += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
console.log(`set ${name}`);
|
|
187
|
+
}
|
|
188
|
+
return failed === 0 ? 0 : 1;
|
|
189
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles the three places a repository's secrets are described, all of
|
|
3
|
+
* which smoo already owns:
|
|
4
|
+
*
|
|
5
|
+
* 1. **Workers declare names.** `.dev.vars.example` per wrangler project — the
|
|
6
|
+
* file `wrangler types --env-file` reads and `prepare-env` prompts from.
|
|
7
|
+
* 2. **Workflows declare where values come from.** `smoo.github.deploySecrets`
|
|
8
|
+
* and `e2eSecrets` map an env name to a repository secret, and the managed
|
|
9
|
+
* workflows render exactly those into the job environment.
|
|
10
|
+
* 3. **A developer shell declares how to fetch one locally.** `smoo.secrets`
|
|
11
|
+
* names a command whose stdout is the value.
|
|
12
|
+
*
|
|
13
|
+
* Nothing joined them, so a Worker could declare a secret no workflow supplies
|
|
14
|
+
* and no repository holds. That fails at deploy time, on a stage, with a
|
|
15
|
+
* fail-closed message about the value rather than about the missing
|
|
16
|
+
* declaration — the shape that costs an afternoon: a Worker declares
|
|
17
|
+
* `STRIPE_PUBLISHABLE_KEY`, `ci.yml` passes `secrets.STRIPE_PUBLISHABLE_KEY`,
|
|
18
|
+
* the repository holds no such secret, and the empty value surfaces as the
|
|
19
|
+
* payment library's own `publishable_key_mismatch` refusal.
|
|
20
|
+
*/
|
|
21
|
+
/** Where an env name is described, which repository secret carries it, and whether that exists. */
|
|
22
|
+
export interface SecretRow {
|
|
23
|
+
name: string;
|
|
24
|
+
/** The repository secret this env name reads from, by convention or declaration. */
|
|
25
|
+
repositorySecret: string;
|
|
26
|
+
/** Wrangler projects whose `.dev.vars.example` declares it. */
|
|
27
|
+
declaredByWorkers: string[];
|
|
28
|
+
/** True when a managed workflow renders `secrets.<name>` into a job. */
|
|
29
|
+
suppliedByWorkflow: boolean;
|
|
30
|
+
/** True when `smoo.secrets` can fetch it for a developer shell. */
|
|
31
|
+
fetchableLocally: boolean;
|
|
32
|
+
/** True when the repository holds a secret of this name. */
|
|
33
|
+
onRepository: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface SecretSources {
|
|
36
|
+
/** Worker label -> env names it declares. */
|
|
37
|
+
workerSecrets: Record<string, readonly string[]>;
|
|
38
|
+
/** Env names a managed workflow passes into a job. */
|
|
39
|
+
workflowSecrets: readonly string[];
|
|
40
|
+
/** Env name -> repository secret, by convention with declared exceptions. */
|
|
41
|
+
secretNames: Readonly<Record<string, string>>;
|
|
42
|
+
/** Env names `smoo.secrets` can fetch locally. */
|
|
43
|
+
localCommands: readonly string[];
|
|
44
|
+
/** Repository secret names GitHub currently holds. */
|
|
45
|
+
repositorySecrets: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One row per name known to any source, sorted, so a reader sees the whole
|
|
49
|
+
* picture rather than one source's view of it.
|
|
50
|
+
*/
|
|
51
|
+
export declare function reconcileSecrets(sources: SecretSources): SecretRow[];
|
|
52
|
+
/**
|
|
53
|
+
* The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
|
|
54
|
+
* promises to pass it, and the repository has no value to pass. Reported
|
|
55
|
+
* separately from "declared but not wired into any workflow", because the
|
|
56
|
+
* remedies differ — set a secret, versus declare it in `smoo.github`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function unsatisfiedSecrets(rows: readonly SecretRow[]): SecretRow[];
|
|
59
|
+
/** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
|
|
60
|
+
export declare function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[];
|
|
61
|
+
/** Env names the managed workflows pass into a job, from the declarations that render them. */
|
|
62
|
+
export declare function workflowSecretNames(root: string): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Env name -> repository secret for every name in play, following the naming
|
|
65
|
+
* convention and honouring the declared exceptions a repository still needs.
|
|
66
|
+
*/
|
|
67
|
+
export declare function secretNameMapping(root: string, envNames: readonly string[]): Record<string, string>;
|
|
68
|
+
/** Every wrangler project's declared secret names, keyed by the project directory. */
|
|
69
|
+
export declare function workerSecretNames(root: string, workspaceDirs: readonly string[]): Record<string, string[]>;
|
|
70
|
+
/** Names `smoo.secrets` declares a fetch command for. */
|
|
71
|
+
export declare function localSecretCommandNames(root: string): string[];
|
|
72
|
+
/**
|
|
73
|
+
* Resolve one declared secret through its command. The value is returned, never
|
|
74
|
+
* logged: callers hand it to `gh secret set` over stdin.
|
|
75
|
+
*/
|
|
76
|
+
export declare function fetchLocalSecret(root: string, name: string): {
|
|
77
|
+
ok: true;
|
|
78
|
+
value: string;
|
|
79
|
+
} | {
|
|
80
|
+
ok: false;
|
|
81
|
+
reason: string;
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/secrets/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,mGAAmG;AACnG,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,gBAAgB,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,wEAAwE;IACxE,kBAAkB,EAAE,OAAO,CAAC;IAC5B,mEAAmE;IACnE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,4DAA4D;IAC5D,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,aAAa;IAC5B,6CAA6C;IAC7C,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IACjD,sDAAsD;IACtD,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,6EAA6E;IAC7E,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,kDAAkD;IAClD,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,sDAAsD;IACtD,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,SAAS,EAAE,CA0BpE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,SAAS,EAAE,GAAG,SAAS,EAAE,CAE1E;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,SAAS,EAAE,GAAG,SAAS,EAAE,CAEtE;AAED,+FAA+F;AAC/F,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAkB1D;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMnG;AAED,sFAAsF;AACtF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAS1G;AAED,yDAAyD;AACzD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAG9D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAqB7D"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles the three places a repository's secrets are described, all of
|
|
3
|
+
* which smoo already owns:
|
|
4
|
+
*
|
|
5
|
+
* 1. **Workers declare names.** `.dev.vars.example` per wrangler project — the
|
|
6
|
+
* file `wrangler types --env-file` reads and `prepare-env` prompts from.
|
|
7
|
+
* 2. **Workflows declare where values come from.** `smoo.github.deploySecrets`
|
|
8
|
+
* and `e2eSecrets` map an env name to a repository secret, and the managed
|
|
9
|
+
* workflows render exactly those into the job environment.
|
|
10
|
+
* 3. **A developer shell declares how to fetch one locally.** `smoo.secrets`
|
|
11
|
+
* names a command whose stdout is the value.
|
|
12
|
+
*
|
|
13
|
+
* Nothing joined them, so a Worker could declare a secret no workflow supplies
|
|
14
|
+
* and no repository holds. That fails at deploy time, on a stage, with a
|
|
15
|
+
* fail-closed message about the value rather than about the missing
|
|
16
|
+
* declaration — the shape that costs an afternoon: a Worker declares
|
|
17
|
+
* `STRIPE_PUBLISHABLE_KEY`, `ci.yml` passes `secrets.STRIPE_PUBLISHABLE_KEY`,
|
|
18
|
+
* the repository holds no such secret, and the empty value surfaces as the
|
|
19
|
+
* payment library's own `publishable_key_mismatch` refusal.
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
|
|
25
|
+
import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
|
|
26
|
+
import { parseDevVarsExample } from '../wrangler/prepare-env.js';
|
|
27
|
+
/**
|
|
28
|
+
* One row per name known to any source, sorted, so a reader sees the whole
|
|
29
|
+
* picture rather than one source's view of it.
|
|
30
|
+
*/
|
|
31
|
+
export function reconcileSecrets(sources) {
|
|
32
|
+
const declaredByAnyWorker = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
|
|
33
|
+
const names = new Set([...declaredByAnyWorker, ...sources.workflowSecrets, ...sources.localCommands]);
|
|
34
|
+
// A repository secret that already carries a known env name is that name's
|
|
35
|
+
// row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
|
|
36
|
+
// GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
|
|
37
|
+
// permanently "declared by nothing".
|
|
38
|
+
const carriesKnownEnvName = new Set(names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : []);
|
|
39
|
+
for (const secret of sources.repositorySecrets) {
|
|
40
|
+
if (!carriesKnownEnvName.has(secret))
|
|
41
|
+
names.add(secret);
|
|
42
|
+
}
|
|
43
|
+
return [...names]
|
|
44
|
+
.sort((left, right) => left.localeCompare(right))
|
|
45
|
+
.map((name) => ({
|
|
46
|
+
name,
|
|
47
|
+
repositorySecret: sources.secretNames[name] ?? name,
|
|
48
|
+
declaredByWorkers: Object.entries(sources.workerSecrets)
|
|
49
|
+
.filter(([, declared]) => declared.includes(name))
|
|
50
|
+
.map(([label]) => label)
|
|
51
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
52
|
+
suppliedByWorkflow: sources.workflowSecrets.includes(name),
|
|
53
|
+
fetchableLocally: sources.localCommands.includes(name),
|
|
54
|
+
onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
|
|
59
|
+
* promises to pass it, and the repository has no value to pass. Reported
|
|
60
|
+
* separately from "declared but not wired into any workflow", because the
|
|
61
|
+
* remedies differ — set a secret, versus declare it in `smoo.github`.
|
|
62
|
+
*/
|
|
63
|
+
export function unsatisfiedSecrets(rows) {
|
|
64
|
+
return rows.filter((row) => row.suppliedByWorkflow && !row.onRepository);
|
|
65
|
+
}
|
|
66
|
+
/** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
|
|
67
|
+
export function unwiredSecrets(rows) {
|
|
68
|
+
return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
|
|
69
|
+
}
|
|
70
|
+
/** Env names the managed workflows pass into a job, from the declarations that render them. */
|
|
71
|
+
export function workflowSecretNames(root) {
|
|
72
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
73
|
+
const smoo = manifest?.smoo;
|
|
74
|
+
const github = smoo?.github;
|
|
75
|
+
const names = new Set([
|
|
76
|
+
...Object.keys(github?.deploySecrets ?? {}),
|
|
77
|
+
...Object.keys(github?.e2eSecrets ?? {}),
|
|
78
|
+
]);
|
|
79
|
+
if (smoo?.remoteCache?.tokenSecret)
|
|
80
|
+
names.add(smoo.remoteCache.tokenSecret);
|
|
81
|
+
for (const origin of github?.cargoCredentials?.gitOrigins ?? []) {
|
|
82
|
+
if (origin.tokenEnv)
|
|
83
|
+
names.add(origin.tokenEnv);
|
|
84
|
+
}
|
|
85
|
+
for (const source of github?.sourceCheckouts ?? []) {
|
|
86
|
+
if (source.tokenEnv)
|
|
87
|
+
names.add(source.tokenEnv);
|
|
88
|
+
}
|
|
89
|
+
if (smoo?.privateNpm?.readTokenEnv)
|
|
90
|
+
names.add(smoo.privateNpm.readTokenEnv);
|
|
91
|
+
if (smoo?.privateNpm?.publishTokenEnv)
|
|
92
|
+
names.add(smoo.privateNpm.publishTokenEnv);
|
|
93
|
+
return [...names].sort((left, right) => left.localeCompare(right));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Env name -> repository secret for every name in play, following the naming
|
|
97
|
+
* convention and honouring the declared exceptions a repository still needs.
|
|
98
|
+
*/
|
|
99
|
+
export function secretNameMapping(root, envNames) {
|
|
100
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
101
|
+
const declared = { ...(manifest?.smoo?.github?.deploySecrets ?? {}), ...(manifest?.smoo?.github?.e2eSecrets ?? {}) };
|
|
102
|
+
const repository = manifest ? repositoryInfo(manifest) : null;
|
|
103
|
+
const owner = repository ? (repositoryOwnerFromUrl(repository.url) ?? '') : '';
|
|
104
|
+
return repositorySecretMapping(envNames, owner, declared);
|
|
105
|
+
}
|
|
106
|
+
/** Every wrangler project's declared secret names, keyed by the project directory. */
|
|
107
|
+
export function workerSecretNames(root, workspaceDirs) {
|
|
108
|
+
const byWorker = {};
|
|
109
|
+
for (const dir of workspaceDirs) {
|
|
110
|
+
const examplePath = join(root, dir, '.dev.vars.example');
|
|
111
|
+
if (!existsSync(examplePath))
|
|
112
|
+
continue;
|
|
113
|
+
const names = parseDevVarsExample(readFileSync(examplePath, 'utf8'));
|
|
114
|
+
if (names.length > 0)
|
|
115
|
+
byWorker[dir] = names;
|
|
116
|
+
}
|
|
117
|
+
return byWorker;
|
|
118
|
+
}
|
|
119
|
+
/** Names `smoo.secrets` declares a fetch command for. */
|
|
120
|
+
export function localSecretCommandNames(root) {
|
|
121
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
122
|
+
return Object.keys(manifest?.smoo?.secrets ?? {}).sort((left, right) => left.localeCompare(right));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve one declared secret through its command. The value is returned, never
|
|
126
|
+
* logged: callers hand it to `gh secret set` over stdin.
|
|
127
|
+
*/
|
|
128
|
+
export function fetchLocalSecret(root, name) {
|
|
129
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
130
|
+
const declared = manifest?.smoo?.secrets?.[name];
|
|
131
|
+
if (!declared) {
|
|
132
|
+
return { ok: false, reason: `smoo.secrets declares no command for ${name}` };
|
|
133
|
+
}
|
|
134
|
+
const [command, ...args] = declared.command;
|
|
135
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
136
|
+
if (result.status !== 0) {
|
|
137
|
+
// Name the command, never its output: a failing secret fetch prints
|
|
138
|
+
// credentials often enough that it is worth refusing to.
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
reason: `${name}: \`${declared.command.join(' ')}\` exited ${result.status ?? 'without status'}`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const value = result.stdout.replace(/\n$/, '');
|
|
145
|
+
if (value.length === 0) {
|
|
146
|
+
return { ok: false, reason: `${name}: \`${declared.command.join(' ')}\` produced no value` };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, value };
|
|
149
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CloudflareClient } from './cloudflare.js';
|
|
2
|
+
import { type VersionEndpointWaitOptions } from './live-version.js';
|
|
2
3
|
import { type DeploymentStage } from './stage.js';
|
|
3
4
|
export interface ProcessResult {
|
|
4
5
|
exitCode: number;
|
|
@@ -24,6 +25,10 @@ export interface WranglerCommandDependencies {
|
|
|
24
25
|
runner?: ProcessRunner;
|
|
25
26
|
cloudflare?: CloudflareClient;
|
|
26
27
|
processEnv?: NodeJS.ProcessEnv;
|
|
28
|
+
/** Bounds and clock for the post-deploy wait; tests drive it without sleeping. */
|
|
29
|
+
wait?: VersionEndpointWaitOptions;
|
|
30
|
+
/** Where a successful deploy records the tag it made live, for `smoo wrangler deployed-version`. */
|
|
31
|
+
liveVersionCacheDirectory?: string;
|
|
27
32
|
}
|
|
28
33
|
export interface DeployStageResult {
|
|
29
34
|
stage: DeploymentStage;
|
|
@@ -36,6 +41,12 @@ export interface DeployStageOptions {
|
|
|
36
41
|
stage: string;
|
|
37
42
|
/** A build-generated flat wrangler.json to deploy instead of `./wrangler.toml` (see `prepareFlatConfig`). */
|
|
38
43
|
config?: string;
|
|
44
|
+
/**
|
|
45
|
+
* A URL, served by this worker, whose trimmed body is the running version tag. When given, the
|
|
46
|
+
* deploy is not done until that URL answers with the tag it just made live — the control plane
|
|
47
|
+
* accepting a traffic shift is not the edge serving it.
|
|
48
|
+
*/
|
|
49
|
+
versionEndpoint?: string;
|
|
39
50
|
}
|
|
40
51
|
export declare function deployStage(cwd: string, options: DeployStageOptions, dependencies?: WranglerCommandDependencies): Promise<DeployStageResult>;
|
|
41
52
|
/**
|
|
@@ -60,7 +71,7 @@ export interface CleanupResult {
|
|
|
60
71
|
}
|
|
61
72
|
export declare function cleanupPullRequest(cwd: string, prNumber: number, dependencies?: WranglerCommandDependencies): Promise<CleanupResult>;
|
|
62
73
|
export declare function nxTaskVersionTag(environment: NodeJS.ProcessEnv): string | undefined;
|
|
63
|
-
|
|
64
|
-
export declare function
|
|
74
|
+
/** One wrangler invocation whose stdout must be JSON; shared by the deploy and the read-only query. */
|
|
75
|
+
export declare function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown>;
|
|
65
76
|
export {};
|
|
66
77
|
//# sourceMappingURL=deploy-stage.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deploy-stage.d.ts","sourceRoot":"","sources":["../../src/wrangler/deploy-stage.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"deploy-stage.d.ts","sourceRoot":"","sources":["../../src/wrangler/deploy-stage.ts"],"names":[],"mappings":"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"}
|
|
@@ -2,11 +2,11 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import typia from 'typia';
|
|
6
5
|
import { parseJsonFileText } from '../lib/json.js';
|
|
7
6
|
import { mergeEnv, printCommandOutput } from '../lib/run.js';
|
|
8
7
|
import { CloudflareRestClient } from './cloudflare.js';
|
|
9
8
|
import { parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
|
|
9
|
+
import { awaitLiveVersion, awaitVersionEndpoint, currentDeploymentVersionId, findVersionIdByTag, liveVersionCacheDirectory, writeCachedLiveVersion, } from './live-version.js';
|
|
10
10
|
import { parseDevVarsExample } from './prepare-env.js';
|
|
11
11
|
import { derivePullRequestStageConfig, derivePullRequestWranglerConfig, hasExactStageSegment, isPullRequestStage, parseDeploymentStage, planConfiguredStageResources, planPullRequestBindings, planPullRequestResources, pullRequestStage, } from './stage.js';
|
|
12
12
|
/** The child environment `options` describe; every runner, test doubles included, spawns with exactly this. */
|
|
@@ -30,15 +30,6 @@ export class BunProcessRunner {
|
|
|
30
30
|
return { exitCode, stdout, stderr };
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
const isUnknownRecord = (() => {
|
|
34
|
-
const _io0 = input => Object.keys(input).every(key => {
|
|
35
|
-
const value = input[key];
|
|
36
|
-
if (undefined === value)
|
|
37
|
-
return true;
|
|
38
|
-
return true;
|
|
39
|
-
});
|
|
40
|
-
return input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
|
|
41
|
-
})();
|
|
42
33
|
export async function deployStage(cwd, options, dependencies = {}) {
|
|
43
34
|
const stage = parseDeploymentStage(options.stage);
|
|
44
35
|
const processEnv = dependencies.processEnv ?? process.env;
|
|
@@ -73,15 +64,23 @@ export async function deployStage(cwd, options, dependencies = {}) {
|
|
|
73
64
|
// `reconcileStageResources` nor `versions list` looks at.
|
|
74
65
|
const run = prepared.envFlag ? { cwd } : { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
|
|
75
66
|
const workerName = prepared.plan.workerName;
|
|
67
|
+
const probe = {
|
|
68
|
+
deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
|
|
69
|
+
versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
|
|
70
|
+
};
|
|
76
71
|
// The tagged-version lookup runs before the migrations: a cache hit means this exact build is
|
|
77
72
|
// already live, so its migrations ran with it and re-applying them would touch the remote
|
|
78
73
|
// database for nothing. The activation and upload paths below still migrate first.
|
|
74
|
+
//
|
|
75
|
+
// This reads Cloudflare every time and never the local cache. A cache hit here would mean
|
|
76
|
+
// "we believe this hash is live" — exactly the belief a rollback falsifies — and believing it
|
|
77
|
+
// would skip the deploy that repairs the rollback. Reading live state is what makes a
|
|
78
|
+
// redundant deploy a proven no-op instead of an assumed one, which is in turn what makes a
|
|
79
|
+
// cross-project `dependsOn: ["<other>:deploy"]` edge cheap enough to be the ordering mechanism.
|
|
79
80
|
let taggedVersionId = null;
|
|
80
81
|
if (versionTag && workerExists) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
taggedVersionId = findVersionIdByTag(versions, versionTag);
|
|
84
|
-
if (taggedVersionId && isFullCurrentDeployment(deployments, taggedVersionId)) {
|
|
82
|
+
taggedVersionId = findVersionIdByTag(await probe.versions(), versionTag);
|
|
83
|
+
if (taggedVersionId && currentDeploymentVersionId(await probe.deployments()) === taggedVersionId) {
|
|
85
84
|
return { stage, workerName, action: 'remote-cache-hit', versionTag };
|
|
86
85
|
}
|
|
87
86
|
}
|
|
@@ -101,6 +100,7 @@ export async function deployStage(cwd, options, dependencies = {}) {
|
|
|
101
100
|
...envArgs,
|
|
102
101
|
'--yes',
|
|
103
102
|
], run);
|
|
103
|
+
await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
|
|
104
104
|
return { stage, workerName, action: 'activated', versionTag };
|
|
105
105
|
}
|
|
106
106
|
const deployArgs = ['deploy', '--config', prepared.deployConfigPath, ...envArgs];
|
|
@@ -112,6 +112,9 @@ export async function deployStage(cwd, options, dependencies = {}) {
|
|
|
112
112
|
deployArgs.push('--secrets-file', temporarySecretsPath);
|
|
113
113
|
}
|
|
114
114
|
await wrangler(runner, deployArgs, run);
|
|
115
|
+
if (versionTag) {
|
|
116
|
+
await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
|
|
117
|
+
}
|
|
115
118
|
return { stage, workerName, action: 'deployed', ...(versionTag ? { versionTag } : {}) };
|
|
116
119
|
}
|
|
117
120
|
finally {
|
|
@@ -447,48 +450,30 @@ export function nxTaskVersionTag(environment) {
|
|
|
447
450
|
}
|
|
448
451
|
throw new Error('NX_TASK_HASH must be canonical decimal digits or at least 32 hexadecimal characters.');
|
|
449
452
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return value.id;
|
|
468
|
-
if (typeof value.version_id === 'string')
|
|
469
|
-
return value.version_id;
|
|
470
|
-
}
|
|
471
|
-
for (const nested of Object.values(value)) {
|
|
472
|
-
const found = findVersionIdByTag(nested, tag);
|
|
473
|
-
if (found)
|
|
474
|
-
return found;
|
|
475
|
-
}
|
|
476
|
-
return null;
|
|
477
|
-
}
|
|
478
|
-
export function isFullCurrentDeployment(value, versionId) {
|
|
479
|
-
if (Array.isArray(value))
|
|
480
|
-
return value.some((entry) => isFullCurrentDeployment(entry, versionId));
|
|
481
|
-
if (!isUnknownRecord(value))
|
|
482
|
-
return false;
|
|
483
|
-
if (Array.isArray(value.versions)) {
|
|
484
|
-
return (value.versions.length === 1 &&
|
|
485
|
-
value.versions.some((version) => isUnknownRecord(version) &&
|
|
486
|
-
(version.version_id === versionId || version.id === versionId) &&
|
|
487
|
-
Number(version.percentage) === 100));
|
|
453
|
+
/**
|
|
454
|
+
* Turns "Cloudflare accepted the traffic shift" into "the new version answers".
|
|
455
|
+
*
|
|
456
|
+
* Without this the deploy step goes green while the edge still serves the previous version — the
|
|
457
|
+
* measured gap was about 20 s — so anything ordered after this deploy could call into code that
|
|
458
|
+
* has not shipped yet. The wait is what a `dependsOn: ["<other>:deploy"]` edge actually buys; an
|
|
459
|
+
* edge onto a step that returns early orders nothing.
|
|
460
|
+
*/
|
|
461
|
+
async function confirmVersionIsLive(probe, versionTag, identity, options, dependencies, cwd) {
|
|
462
|
+
const wait = dependencies.wait ?? {};
|
|
463
|
+
const live = await awaitLiveVersion(probe, versionTag, wait);
|
|
464
|
+
if (!live.ok)
|
|
465
|
+
throw new Error(`${identity.workerName}: ${live.error.message}`);
|
|
466
|
+
if (options.versionEndpoint) {
|
|
467
|
+
const answered = await awaitVersionEndpoint(options.versionEndpoint, versionTag, wait);
|
|
468
|
+
if (!answered.ok)
|
|
469
|
+
throw new Error(`${identity.workerName}: ${answered.error.message}`);
|
|
488
470
|
}
|
|
489
|
-
|
|
471
|
+
const processEnv = dependencies.processEnv ?? process.env;
|
|
472
|
+
const directory = dependencies.liveVersionCacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
|
|
473
|
+
await writeCachedLiveVersion(directory, { accountId: identity.accountId, workerName: identity.workerName, stage: identity.stage }, { versionTag, versionId: live.value.versionId, fetchedAt: Date.now() });
|
|
490
474
|
}
|
|
491
|
-
|
|
475
|
+
/** One wrangler invocation whose stdout must be JSON; shared by the deploy and the read-only query. */
|
|
476
|
+
export async function wranglerJson(runner, args, run) {
|
|
492
477
|
const result = await wrangler(runner, args, run);
|
|
493
478
|
try {
|
|
494
479
|
return JSON.parse(result.stdout);
|