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.
@@ -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
+ }
@@ -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
+ }