code-foundry 1.10.1 → 1.12.0
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/.github/code-foundry.yml +4 -0
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/cloudflare-delivery.yml +354 -0
- package/.github/workflows/cloudflare-deploy.yml +90 -47
- package/.github/workflows/codeql.yml +2 -0
- package/.github/workflows/security.yml +2 -0
- package/.github/workflows/test.yml +5 -0
- package/.github/workflows/validation-no-codeql.yml +1 -0
- package/.github/workflows/validation.yml +1 -0
- package/.github/workflows/validation_self-ci.yml +1 -0
- package/CHANGELOG.md +14 -0
- package/docs/CONFIGURATION.md +23 -10
- package/docs/README.md +1 -0
- package/docs/cloudflare-delivery.md +188 -0
- package/docs/required-capabilities.md +82 -0
- package/package.json +1 -1
- package/src/commands/cloudflare-delivery.mjs +351 -0
- package/src/commands/sync.mjs +4 -0
- package/src/lib/cloudflare-delivery.mjs +233 -0
- package/src/lib/task-policy.mjs +290 -0
- package/src/runtime-core.mjs +794 -0
- package/src/runtime.mjs +179 -764
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import {
|
|
9
|
+
bindingPolicy,
|
|
10
|
+
deploymentVersions,
|
|
11
|
+
digestBuild,
|
|
12
|
+
httpsUrl,
|
|
13
|
+
inspectBindings,
|
|
14
|
+
parseArgv,
|
|
15
|
+
parseUpload,
|
|
16
|
+
pinnedWrangler,
|
|
17
|
+
validateRollback,
|
|
18
|
+
versionId,
|
|
19
|
+
} from '../lib/cloudflare-delivery.mjs'
|
|
20
|
+
|
|
21
|
+
/** @param {NodeJS.ProcessEnv} env @param {string} key */
|
|
22
|
+
function required(env, key) {
|
|
23
|
+
const value = env[key]
|
|
24
|
+
if (!value || /[\r\n\0]/.test(value)) throw new Error(`Missing or invalid ${key}`)
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {NodeJS.ProcessEnv} env @param {string} name @param {unknown} value */
|
|
29
|
+
function output(env, name, value) {
|
|
30
|
+
const text = String(value)
|
|
31
|
+
if (/[\r\n\0]/.test(text)) throw new Error(`Invalid output ${name}`)
|
|
32
|
+
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${name}=${text}\n`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {string} url @param {string} token @param {unknown} [body] @param {typeof fetch} [request] */
|
|
36
|
+
export async function requestJson(url, token, body, request = fetch) {
|
|
37
|
+
const response = await request(url, {
|
|
38
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${token}`,
|
|
41
|
+
Accept: 'application/json',
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'User-Agent': 'code-foundry',
|
|
44
|
+
},
|
|
45
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
46
|
+
signal: AbortSignal.timeout(30000),
|
|
47
|
+
redirect: 'error',
|
|
48
|
+
})
|
|
49
|
+
if (!response.ok) throw new Error(`Deployment API request failed (${response.status})`)
|
|
50
|
+
const json = /** @type {any} */ (await response.json())
|
|
51
|
+
if (json.success === false) throw new Error('Cloudflare rejected the deployment API request')
|
|
52
|
+
return json
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {NodeJS.ProcessEnv} env @param {string} suffix @param {unknown} [body] */
|
|
56
|
+
async function cloudflare(env, suffix, body) {
|
|
57
|
+
const account = encodeURIComponent(required(env, 'CLOUDFLARE_ACCOUNT_ID'))
|
|
58
|
+
const worker = encodeURIComponent(required(env, 'FOUNDRY_WORKER'))
|
|
59
|
+
const response = await requestJson(
|
|
60
|
+
`https://api.cloudflare.com/client/v4/accounts/${account}/workers/scripts/${worker}/${suffix}`,
|
|
61
|
+
required(env, 'CLOUDFLARE_API_TOKEN'),
|
|
62
|
+
body
|
|
63
|
+
)
|
|
64
|
+
if (response.success !== true || !Object.hasOwn(response, 'result'))
|
|
65
|
+
throw new Error('Invalid Cloudflare API envelope')
|
|
66
|
+
return response.result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {NodeJS.ProcessEnv} env @param {string} suffix @param {unknown} [body] */
|
|
70
|
+
async function github(env, suffix, body) {
|
|
71
|
+
const repository = required(env, 'GITHUB_REPOSITORY')
|
|
72
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error('Invalid GitHub repository')
|
|
73
|
+
return requestJson(
|
|
74
|
+
`https://api.github.com/repos/${repository}/${suffix}`,
|
|
75
|
+
required(env, 'GH_TOKEN'),
|
|
76
|
+
body
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {any} result */
|
|
81
|
+
export function latestDeployment(result) {
|
|
82
|
+
if (!Array.isArray(result?.deployments)) throw new Error('Invalid deployments response')
|
|
83
|
+
if (!result.deployments.length) return null
|
|
84
|
+
const deployments = [...result.deployments]
|
|
85
|
+
if (deployments.some((item) => !Number.isFinite(Date.parse(item.created_on))))
|
|
86
|
+
throw new Error('Deployment timestamp is missing')
|
|
87
|
+
// Sorting this copy leaves API input untouched.
|
|
88
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
89
|
+
deployments.sort((a, b) => Date.parse(b.created_on) - Date.parse(a.created_on))
|
|
90
|
+
return deployments[0]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {NodeJS.ProcessEnv} env @param {any} state */
|
|
94
|
+
async function assertCurrent(env, state) {
|
|
95
|
+
const branch = required(env, 'FOUNDRY_DEFAULT_BRANCH')
|
|
96
|
+
if (env.GITHUB_REF !== `refs/heads/${branch}`)
|
|
97
|
+
throw new Error('Production delivery requires the default branch')
|
|
98
|
+
const latest = await github(env, `git/ref/heads/${encodeURIComponent(branch)}`)
|
|
99
|
+
if (latest?.object?.sha !== state.sourceSha)
|
|
100
|
+
throw new Error('A newer source commit exists; refusing stale deployment')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {NodeJS.ProcessEnv} env @param {any} state @param {number} percentage */
|
|
104
|
+
async function activate(env, state, percentage) {
|
|
105
|
+
if (state.candidateVerified !== true || !state.bindingInspection)
|
|
106
|
+
throw new Error('Candidate verification is required before promotion')
|
|
107
|
+
const version = await cloudflare(env, `versions/${versionId(state.versionId)}`)
|
|
108
|
+
if (version.id !== state.versionId) throw new Error('Candidate version identity changed')
|
|
109
|
+
state.bindingInspection = inspectBindings(
|
|
110
|
+
version,
|
|
111
|
+
bindingPolicy(process.cwd(), env.BINDING_POLICY_FILE ?? ''),
|
|
112
|
+
'production'
|
|
113
|
+
)
|
|
114
|
+
const current = latestDeployment(await cloudflare(env, 'deployments'))
|
|
115
|
+
if (!Object.hasOwn(state, 'previous')) state.previous = current?.versions ?? []
|
|
116
|
+
else if (state.cloudflareDeploymentId && current?.id !== state.cloudflareDeploymentId)
|
|
117
|
+
throw new Error('Another deployment replaced this rollout; refusing to overwrite it')
|
|
118
|
+
const versions = deploymentVersions(state.versionId, percentage, state.previous)
|
|
119
|
+
const result = await cloudflare(env, 'deployments', {
|
|
120
|
+
strategy: 'percentage',
|
|
121
|
+
versions,
|
|
122
|
+
annotations: { 'workers/message': `Code Foundry ${state.sourceSha} (${percentage}%)` },
|
|
123
|
+
})
|
|
124
|
+
state.cloudflareDeploymentId = versionId(result.id, 'deployment ID')
|
|
125
|
+
state.percentage = percentage
|
|
126
|
+
output(env, 'cloudflare-deployment-id', state.cloudflareDeploymentId)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {NodeJS.ProcessEnv} env @param {any} state */
|
|
130
|
+
async function rollback(env, state) {
|
|
131
|
+
if (env.AUTO_ROLLBACK !== 'true')
|
|
132
|
+
throw new Error(
|
|
133
|
+
'Automatic rollback is disabled; use the recorded prior versions for manual recovery'
|
|
134
|
+
)
|
|
135
|
+
if (state.bindingInspection?.rollbackSafe !== true)
|
|
136
|
+
throw new Error('Automatic rollback is restricted to stateless Workers')
|
|
137
|
+
if (!state.cloudflareDeploymentId) throw new Error('This run has not activated a deployment')
|
|
138
|
+
const current = latestDeployment(await cloudflare(env, 'deployments'))
|
|
139
|
+
if (current?.id !== state.cloudflareDeploymentId)
|
|
140
|
+
throw new Error('Current deployment changed; refusing to roll back another rollout')
|
|
141
|
+
const versions = validateRollback(state.previous)
|
|
142
|
+
for (const previous of versions) {
|
|
143
|
+
const version = await cloudflare(env, `versions/${previous.version_id}`)
|
|
144
|
+
const inspection = inspectBindings(
|
|
145
|
+
version,
|
|
146
|
+
bindingPolicy(process.cwd(), env.BINDING_POLICY_FILE ?? ''),
|
|
147
|
+
'production'
|
|
148
|
+
)
|
|
149
|
+
if (!inspection.stateless)
|
|
150
|
+
throw new Error('The previous version is stateful; automatic rollback is unsafe')
|
|
151
|
+
}
|
|
152
|
+
const result = await cloudflare(env, 'deployments', {
|
|
153
|
+
strategy: 'percentage',
|
|
154
|
+
versions,
|
|
155
|
+
annotations: {
|
|
156
|
+
'workers/message': `Code Foundry rollback after failed verification of ${state.sourceSha}`,
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
state.rollbackDeploymentId = versionId(result.id, 'rollback deployment ID')
|
|
160
|
+
state.rolledBack = true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {NodeJS.ProcessEnv} env @param {any} state */
|
|
164
|
+
async function verify(env, state) {
|
|
165
|
+
if (!Array.isArray(state.bindingInspection?.bindings))
|
|
166
|
+
throw new Error('Binding inspection is required before verification')
|
|
167
|
+
const phase = env.FOUNDRY_PHASE ?? 'candidate'
|
|
168
|
+
if (!['candidate', 'canary', 'production'].includes(phase))
|
|
169
|
+
throw new Error('Invalid verification phase')
|
|
170
|
+
const url = httpsUrl(phase === 'candidate' ? state.url : required(env, 'PRODUCTION_URL'))
|
|
171
|
+
const argv = parseArgv(required(env, 'VERIFY_COMMAND'), 'verify-command')
|
|
172
|
+
const smokePath = env.SMOKE_PATH ?? '/'
|
|
173
|
+
if (!smokePath.startsWith('/') || smokePath.startsWith('//'))
|
|
174
|
+
throw new Error('smoke-path must be an origin-relative path')
|
|
175
|
+
const target = new URL(smokePath, url)
|
|
176
|
+
if (target.origin !== new URL(url).origin)
|
|
177
|
+
throw new Error('smoke-path must remain on the deployment origin')
|
|
178
|
+
let healthy = false
|
|
179
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(target, {
|
|
182
|
+
redirect: 'manual',
|
|
183
|
+
signal: AbortSignal.timeout(10000),
|
|
184
|
+
})
|
|
185
|
+
await response.body?.cancel()
|
|
186
|
+
healthy = response.ok
|
|
187
|
+
} catch {
|
|
188
|
+
healthy = false
|
|
189
|
+
}
|
|
190
|
+
if (healthy) break
|
|
191
|
+
if (attempt < 2) await new Promise((done) => setTimeout(done, 2000))
|
|
192
|
+
}
|
|
193
|
+
if (!healthy) throw new Error(`${phase} HTTP smoke check failed`)
|
|
194
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
195
|
+
const childEnv = {
|
|
196
|
+
...env,
|
|
197
|
+
BASE_URL: url,
|
|
198
|
+
FOUNDRY_DEPLOYMENT_PHASE: phase,
|
|
199
|
+
FOUNDRY_EXPECTED_VERSION_ID: state.versionId,
|
|
200
|
+
FOUNDRY_DEPLOYMENT_ID: state.cloudflareDeploymentId ?? '',
|
|
201
|
+
FOUNDRY_CANARY_PERCENTAGE: phase === 'canary' ? String(state.percentage ?? '') : '',
|
|
202
|
+
}
|
|
203
|
+
delete childEnv.CLOUDFLARE_API_TOKEN
|
|
204
|
+
delete childEnv.GH_TOKEN
|
|
205
|
+
delete childEnv.GITHUB_TOKEN
|
|
206
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
207
|
+
cwd: process.cwd(),
|
|
208
|
+
stdio: 'inherit',
|
|
209
|
+
env: childEnv,
|
|
210
|
+
timeout: 600000,
|
|
211
|
+
})
|
|
212
|
+
if (result.error) throw result.error
|
|
213
|
+
if (result.status !== 0)
|
|
214
|
+
throw new Error(`${phase} verification command failed (${result.status ?? result.signal})`)
|
|
215
|
+
state.verifications ??= []
|
|
216
|
+
state.verifications.push({ phase, status: 'passed', completedAt: new Date().toISOString() })
|
|
217
|
+
if (phase === 'candidate') {
|
|
218
|
+
state.candidateVerified = true
|
|
219
|
+
output(env, 'verified', 'true')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @param {string} command @param {NodeJS.ProcessEnv} [env] */
|
|
224
|
+
export async function deliveryCommand(command, env = process.env) {
|
|
225
|
+
const file = required(env, 'FOUNDRY_STATE_FILE')
|
|
226
|
+
/** @type {any} */
|
|
227
|
+
const state = existsSync(file)
|
|
228
|
+
? JSON.parse(readFileSync(file, 'utf8'))
|
|
229
|
+
: {
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
kind: 'code-foundry-cloudflare-delivery',
|
|
232
|
+
sourceSha: required(env, 'GITHUB_SHA'),
|
|
233
|
+
worker: required(env, 'FOUNDRY_WORKER'),
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
!/^[0-9a-f]{40}$/i.test(state.sourceSha) ||
|
|
237
|
+
state.sourceSha !== env.GITHUB_SHA ||
|
|
238
|
+
state.worker !== env.FOUNDRY_WORKER
|
|
239
|
+
)
|
|
240
|
+
throw new Error('Delivery state does not match the checked-out source and Worker')
|
|
241
|
+
try {
|
|
242
|
+
if (command === 'init-production') {
|
|
243
|
+
if (env.CANDIDATE_VERIFIED !== 'true')
|
|
244
|
+
throw new Error('Upstream candidate verification did not pass')
|
|
245
|
+
if (env.CANDIDATE_SOURCE_SHA !== state.sourceSha)
|
|
246
|
+
throw new Error('Candidate source SHA mismatch')
|
|
247
|
+
state.versionId = versionId(env.CANDIDATE_VERSION_ID)
|
|
248
|
+
state.url = httpsUrl(required(env, 'CANDIDATE_URL'))
|
|
249
|
+
state.artifactDigest = required(env, 'CANDIDATE_ARTIFACT_DIGEST')
|
|
250
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(state.artifactDigest))
|
|
251
|
+
throw new Error('Invalid build artifact digest')
|
|
252
|
+
state.candidateVerified = true
|
|
253
|
+
state.bindingInspection = {}
|
|
254
|
+
} else if (command === 'upload') {
|
|
255
|
+
if (!['preview', 'production'].includes(env.FOUNDRY_MODE ?? ''))
|
|
256
|
+
throw new Error('Unsupported delivery mode')
|
|
257
|
+
const event = env.GITHUB_EVENT_NAME
|
|
258
|
+
if (event === 'pull_request_target')
|
|
259
|
+
throw new Error('Privileged pull_request_target execution is not supported')
|
|
260
|
+
state.artifactDigest = digestBuild(process.cwd(), required(env, 'ARTIFACT_PATH'))
|
|
261
|
+
const outputFile = `${file}.wrangler.ndjson`
|
|
262
|
+
writeFileSync(outputFile, '')
|
|
263
|
+
const argv = pinnedWrangler(env.WRANGLER_VERSION ?? 'local')
|
|
264
|
+
argv.push('versions', 'upload', '--message', `Code Foundry ${state.sourceSha}`)
|
|
265
|
+
if (env.WRANGLER_ENVIRONMENT) argv.push('--env', env.WRANGLER_ENVIRONMENT)
|
|
266
|
+
if (env.WRANGLER_CONFIG) argv.push('--config', env.WRANGLER_CONFIG)
|
|
267
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
268
|
+
cwd: process.cwd(),
|
|
269
|
+
stdio: 'inherit',
|
|
270
|
+
env: {
|
|
271
|
+
...env,
|
|
272
|
+
WRANGLER_OUTPUT_FILE_PATH: outputFile,
|
|
273
|
+
WRANGLER_SEND_METRICS: 'false',
|
|
274
|
+
NO_COLOR: '1',
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
if (result.error) throw result.error
|
|
278
|
+
if (result.status !== 0)
|
|
279
|
+
throw new Error(`Wrangler upload failed (${result.status ?? result.signal})`)
|
|
280
|
+
if (digestBuild(process.cwd(), required(env, 'ARTIFACT_PATH')) !== state.artifactDigest)
|
|
281
|
+
throw new Error('Build artifacts changed during upload; disable duplicate custom builds')
|
|
282
|
+
Object.assign(state, parseUpload(readFileSync(outputFile, 'utf8'), state.worker))
|
|
283
|
+
output(env, 'url', state.url)
|
|
284
|
+
output(env, 'version-id', state.versionId)
|
|
285
|
+
output(env, 'artifact-digest', state.artifactDigest)
|
|
286
|
+
output(env, 'source-sha', state.sourceSha)
|
|
287
|
+
} else if (command === 'inspect') {
|
|
288
|
+
const version = await cloudflare(env, `versions/${versionId(state.versionId)}`)
|
|
289
|
+
if (version.id !== state.versionId) throw new Error('Version API identity mismatch')
|
|
290
|
+
const mode = env.FOUNDRY_MODE === 'production' ? 'production' : 'preview'
|
|
291
|
+
state.bindingInspection = inspectBindings(
|
|
292
|
+
version,
|
|
293
|
+
bindingPolicy(process.cwd(), env.BINDING_POLICY_FILE ?? ''),
|
|
294
|
+
mode
|
|
295
|
+
)
|
|
296
|
+
} else if (command === 'verify') await verify(env, state)
|
|
297
|
+
else if (command === 'assert-current') await assertCurrent(env, state)
|
|
298
|
+
else if (command === 'activate')
|
|
299
|
+
await activate(env, state, Number(env.DEPLOY_PERCENTAGE ?? '100'))
|
|
300
|
+
else if (command === 'rollback') await rollback(env, state)
|
|
301
|
+
else if (command === 'record-start') {
|
|
302
|
+
const production = env.FOUNDRY_PHASE === 'production'
|
|
303
|
+
const deployment = await github(env, 'deployments', {
|
|
304
|
+
ref: state.sourceSha,
|
|
305
|
+
environment: required(env, 'DEPLOYMENT_ENVIRONMENT'),
|
|
306
|
+
auto_merge: false,
|
|
307
|
+
required_contexts: [],
|
|
308
|
+
production_environment: production,
|
|
309
|
+
transient_environment: !production,
|
|
310
|
+
task: 'code-foundry-delivery',
|
|
311
|
+
})
|
|
312
|
+
if (!Number.isSafeInteger(deployment.id)) throw new Error('GitHub returned no deployment ID')
|
|
313
|
+
state.githubDeploymentId = deployment.id
|
|
314
|
+
output(env, 'github-deployment-id', deployment.id)
|
|
315
|
+
await github(env, `deployments/${deployment.id}/statuses`, {
|
|
316
|
+
state: 'in_progress',
|
|
317
|
+
auto_inactive: false,
|
|
318
|
+
})
|
|
319
|
+
} else if (command === 'record-finish') {
|
|
320
|
+
if (!state.githubDeploymentId) return
|
|
321
|
+
const status = env.DEPLOYMENT_STATUS === 'success' ? 'success' : 'failure'
|
|
322
|
+
state.status = status
|
|
323
|
+
await github(env, `deployments/${state.githubDeploymentId}/statuses`, {
|
|
324
|
+
state: status,
|
|
325
|
+
auto_inactive: false,
|
|
326
|
+
environment_url:
|
|
327
|
+
env.FOUNDRY_PHASE === 'production'
|
|
328
|
+
? httpsUrl(required(env, 'PRODUCTION_URL'))
|
|
329
|
+
: (state.url ?? ''),
|
|
330
|
+
description: state.rolledBack
|
|
331
|
+
? 'Verification failed; stateless rollback completed'
|
|
332
|
+
: `Code Foundry delivery ${status}`,
|
|
333
|
+
})
|
|
334
|
+
} else throw new Error(`Unknown Cloudflare delivery command: ${command}`)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
state.lastError = error instanceof Error ? error.message : String(error)
|
|
337
|
+
throw error
|
|
338
|
+
} finally {
|
|
339
|
+
state.updatedAt = new Date().toISOString()
|
|
340
|
+
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
345
|
+
try {
|
|
346
|
+
await deliveryCommand(process.argv[2])
|
|
347
|
+
} catch (error) {
|
|
348
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
349
|
+
process.exitCode = 1
|
|
350
|
+
}
|
|
351
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -1288,7 +1288,11 @@ function createDefaultConfig(root, source, configuredWorkflow) {
|
|
|
1288
1288
|
prune_standard: 'false',
|
|
1289
1289
|
cache_packages: 'auto',
|
|
1290
1290
|
cache_build: 'auto',
|
|
1291
|
+
required_capabilities: '',
|
|
1292
|
+
coverage_enforcement: 'auto',
|
|
1291
1293
|
coverage_minimum: '80',
|
|
1294
|
+
coverage_metrics: 'lines',
|
|
1295
|
+
coverage_report: '',
|
|
1292
1296
|
turbo_remote: 'auto',
|
|
1293
1297
|
release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
|
|
1294
1298
|
npm_publish: 'false',
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { relative, resolve, sep } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} value @param {string} label */
|
|
10
|
+
export function versionId(value, label = 'version ID') {
|
|
11
|
+
if (typeof value !== 'string' || !uuid.test(value)) throw new Error(`Invalid ${label}`)
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string} value */
|
|
16
|
+
export function httpsUrl(value) {
|
|
17
|
+
const url = new URL(value)
|
|
18
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash)
|
|
19
|
+
throw new Error('Deployment URLs must be HTTPS without credentials or fragments')
|
|
20
|
+
return url.href
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {string} content @param {string} worker */
|
|
24
|
+
export function parseUpload(content, worker) {
|
|
25
|
+
const entries = content
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.filter((line) => line.trim())
|
|
28
|
+
.map((line) => JSON.parse(line))
|
|
29
|
+
if (entries.some((entry) => entry.type === 'command-failed'))
|
|
30
|
+
throw new Error('Wrangler reported command-failed')
|
|
31
|
+
const uploads = entries.filter((entry) => entry.type === 'version-upload')
|
|
32
|
+
if (uploads.length !== 1) throw new Error('Expected exactly one structured version-upload record')
|
|
33
|
+
const upload = uploads[0]
|
|
34
|
+
if (upload.version !== 1 || upload.worker_name !== worker)
|
|
35
|
+
throw new Error('Unsupported Wrangler output schema or unexpected Worker name')
|
|
36
|
+
const url = httpsUrl(upload.preview_url)
|
|
37
|
+
if (!new URL(url).hostname.endsWith('.workers.dev'))
|
|
38
|
+
throw new Error('Expected a Workers version preview URL')
|
|
39
|
+
return { worker, versionId: versionId(upload.version_id), url }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} value */
|
|
43
|
+
export function pinnedWrangler(value) {
|
|
44
|
+
if (value === 'local') return ['bunx', '--bun', '--no-install', 'wrangler']
|
|
45
|
+
if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(value))
|
|
46
|
+
throw new Error('wrangler-version must be local or an exact version, never latest or a range')
|
|
47
|
+
return ['npx', '--yes', `wrangler@${value}`]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} value @param {string} label */
|
|
51
|
+
export function parseArgv(value, label) {
|
|
52
|
+
const argv = JSON.parse(value)
|
|
53
|
+
if (
|
|
54
|
+
!Array.isArray(argv) ||
|
|
55
|
+
!argv.length ||
|
|
56
|
+
argv.some((arg) => typeof arg !== 'string' || !arg.length || arg.includes('\0'))
|
|
57
|
+
)
|
|
58
|
+
throw new Error(`${label} must be a non-empty JSON argv array`)
|
|
59
|
+
return /** @type {string[]} */ (argv)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Hash a declared build tree, refusing symlinks rather than following external files.
|
|
63
|
+
* @param {string} root @param {string} directory
|
|
64
|
+
*/
|
|
65
|
+
export function digestBuild(root, directory) {
|
|
66
|
+
const base = resolve(root)
|
|
67
|
+
const target = resolve(base, directory)
|
|
68
|
+
const location = relative(base, target)
|
|
69
|
+
if (!location || location === '..' || location.startsWith(`..${sep}`))
|
|
70
|
+
throw new Error('artifact-path must identify a build subdirectory inside the checkout')
|
|
71
|
+
let ancestor = base
|
|
72
|
+
for (const part of location.split(sep)) {
|
|
73
|
+
ancestor = resolve(ancestor, part)
|
|
74
|
+
if (lstatSync(ancestor).isSymbolicLink())
|
|
75
|
+
throw new Error('Build tree must not contain symlinks')
|
|
76
|
+
}
|
|
77
|
+
const hash = createHash('sha256')
|
|
78
|
+
let files = 0
|
|
79
|
+
/** @param {string} currentDirectory */
|
|
80
|
+
function visit(currentDirectory) {
|
|
81
|
+
// Sorting a fresh directory listing does not mutate shared state.
|
|
82
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
83
|
+
const entries = readdirSync(currentDirectory).sort()
|
|
84
|
+
for (const name of entries) {
|
|
85
|
+
const file = resolve(currentDirectory, name)
|
|
86
|
+
const stat = lstatSync(file)
|
|
87
|
+
if (stat.isSymbolicLink()) throw new Error('Build tree must not contain symlinks')
|
|
88
|
+
if (stat.isDirectory()) visit(file)
|
|
89
|
+
else if (stat.isFile()) {
|
|
90
|
+
const content = readFileSync(file)
|
|
91
|
+
hash
|
|
92
|
+
.update(`${relative(target, file).split(sep).join('/')}\0${content.length}\0`)
|
|
93
|
+
.update(content)
|
|
94
|
+
files += 1
|
|
95
|
+
} else throw new Error('Build tree contains a non-regular file')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
visit(target)
|
|
99
|
+
if (!files) throw new Error('Build tree is empty')
|
|
100
|
+
return `sha256:${hash.digest('hex')}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Inspect the actual uploaded version, never log binding values or secret material.
|
|
105
|
+
* Read-only declarations are reviewed test policy, not a sandbox for Worker code.
|
|
106
|
+
* @param {any} version @param {any} policy @param {'preview'|'production'} mode
|
|
107
|
+
*/
|
|
108
|
+
export function inspectBindings(version, policy, mode) {
|
|
109
|
+
const bindings = version?.resources?.bindings
|
|
110
|
+
if (
|
|
111
|
+
Object.values(version?.resources?.script_runtime?.exports ?? {}).some(
|
|
112
|
+
(entry) => /** @type {any} */ (entry)?.type === 'durable-object'
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
throw new Error(
|
|
116
|
+
'Durable Object exports require a repository-specific preview and migration workflow'
|
|
117
|
+
)
|
|
118
|
+
if (!Array.isArray(bindings)) throw new Error('Version API did not return binding metadata')
|
|
119
|
+
if (policy.schemaVersion !== 1) throw new Error('Unsupported binding policy schema')
|
|
120
|
+
if (
|
|
121
|
+
!Array.isArray(policy.readOnlyBindings ?? []) ||
|
|
122
|
+
!Array.isArray(policy.isolatedBindings ?? [])
|
|
123
|
+
)
|
|
124
|
+
throw new Error('Binding policy lists must be arrays')
|
|
125
|
+
const safeTypes = new Set([
|
|
126
|
+
'plain_text',
|
|
127
|
+
'secret_text',
|
|
128
|
+
'json',
|
|
129
|
+
'assets',
|
|
130
|
+
'version_metadata',
|
|
131
|
+
'wasm_module',
|
|
132
|
+
'text_blob',
|
|
133
|
+
'data_blob',
|
|
134
|
+
])
|
|
135
|
+
let stateless = true
|
|
136
|
+
/** @type {{name: string, type: string, policy: string}[]} */
|
|
137
|
+
const result = []
|
|
138
|
+
for (const binding of bindings) {
|
|
139
|
+
if (typeof binding.name !== 'string' || typeof binding.type !== 'string')
|
|
140
|
+
throw new Error('Malformed binding metadata')
|
|
141
|
+
if (binding.type === 'durable_object_namespace')
|
|
142
|
+
throw new Error(
|
|
143
|
+
'Durable Objects require a repository-specific preview and migration workflow'
|
|
144
|
+
)
|
|
145
|
+
if (safeTypes.has(binding.type)) {
|
|
146
|
+
result.push({ name: binding.name, type: binding.type, policy: 'stateless' })
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
stateless = false
|
|
150
|
+
const isolated = (policy.isolatedBindings ?? []).find(
|
|
151
|
+
(/** @type {any} */ entry) => entry.name === binding.name && entry.type === binding.type
|
|
152
|
+
)
|
|
153
|
+
if (isolated) {
|
|
154
|
+
if (mode !== 'preview')
|
|
155
|
+
throw new Error('Isolated preview bindings must never be promoted into production')
|
|
156
|
+
const expected = isolated.expected ?? {}
|
|
157
|
+
const production = isolated.production ?? {}
|
|
158
|
+
const keys = Object.keys(expected)
|
|
159
|
+
if (
|
|
160
|
+
!keys.length ||
|
|
161
|
+
keys.some(
|
|
162
|
+
(key) =>
|
|
163
|
+
['type', 'name'].includes(key) ||
|
|
164
|
+
typeof expected[key] !== 'string' ||
|
|
165
|
+
typeof production[key] !== 'string'
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
throw new Error('Isolated bindings require expected and production resource identifiers')
|
|
169
|
+
if (!keys.some((key) => expected[key] !== production[key]))
|
|
170
|
+
throw new Error('Preview binding reuses production resource identifiers')
|
|
171
|
+
if (keys.some((key) => binding[key] !== expected[key]))
|
|
172
|
+
throw new Error(`Isolated resource mismatch for ${binding.name}`)
|
|
173
|
+
result.push({ name: binding.name, type: binding.type, policy: 'isolated' })
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
const reviewed = (policy.readOnlyBindings ?? []).some(
|
|
177
|
+
(/** @type {any} */ entry) => entry.name === binding.name && entry.type === binding.type
|
|
178
|
+
)
|
|
179
|
+
if (!reviewed) throw new Error(`Unreviewed stateful binding: ${binding.name} (${binding.type})`)
|
|
180
|
+
result.push({ name: binding.name, type: binding.type, policy: 'reviewed-read-only' })
|
|
181
|
+
}
|
|
182
|
+
return { stateless, rollbackSafe: stateless && policy.rollbackSafe === true, bindings: result }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {string} candidate @param {number} percentage @param {any[]} previous */
|
|
186
|
+
export function deploymentVersions(candidate, percentage, previous = []) {
|
|
187
|
+
versionId(candidate)
|
|
188
|
+
if (
|
|
189
|
+
!Number.isFinite(percentage) ||
|
|
190
|
+
percentage < 0.01 ||
|
|
191
|
+
percentage > 100 ||
|
|
192
|
+
(percentage < 100 && percentage > 99.99)
|
|
193
|
+
)
|
|
194
|
+
throw new Error('Canary percentage must be greater than zero and at most 100')
|
|
195
|
+
if (percentage === 100) return [{ version_id: candidate, percentage: 100 }]
|
|
196
|
+
if (previous.length !== 1 || previous[0].percentage !== 100)
|
|
197
|
+
throw new Error('A canary requires exactly one baseline version serving 100%')
|
|
198
|
+
const baseline = versionId(previous[0].version_id)
|
|
199
|
+
if (baseline === candidate) throw new Error('Canary and baseline must be different versions')
|
|
200
|
+
return [
|
|
201
|
+
{ version_id: candidate, percentage },
|
|
202
|
+
{ version_id: baseline, percentage: 100 - percentage },
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @param {any} previous */
|
|
207
|
+
export function validateRollback(previous) {
|
|
208
|
+
if (!Array.isArray(previous) || previous.length < 1 || previous.length > 2)
|
|
209
|
+
throw new Error('No valid rollback deployment is available')
|
|
210
|
+
let total = 0
|
|
211
|
+
const seen = new Set()
|
|
212
|
+
for (const entry of previous) {
|
|
213
|
+
versionId(entry.version_id)
|
|
214
|
+
if (seen.has(entry.version_id)) throw new Error('Duplicate rollback version')
|
|
215
|
+
seen.add(entry.version_id)
|
|
216
|
+
if (!Number.isFinite(entry.percentage) || entry.percentage <= 0 || entry.percentage > 100)
|
|
217
|
+
throw new Error('Invalid rollback percentage')
|
|
218
|
+
total += entry.percentage
|
|
219
|
+
}
|
|
220
|
+
if (Math.abs(total - 100) > 1e-8) throw new Error('Rollback percentages must total 100')
|
|
221
|
+
return previous.map((entry) => ({ version_id: entry.version_id, percentage: entry.percentage }))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {string} root @param {string} file */
|
|
225
|
+
export function bindingPolicy(root, file) {
|
|
226
|
+
if (!file) return { schemaVersion: 1, readOnlyBindings: [], isolatedBindings: [] }
|
|
227
|
+
const path = resolve(root, file)
|
|
228
|
+
const location = relative(resolve(root), path)
|
|
229
|
+
if (location === '..' || location.startsWith(`..${sep}`))
|
|
230
|
+
throw new Error('Binding policy escapes checkout')
|
|
231
|
+
if (!existsSync(path)) throw new Error('Binding policy file is missing')
|
|
232
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
233
|
+
}
|