code-foundry 1.11.0 → 1.13.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
+ }
@@ -181,10 +181,15 @@ export function doctor(root, options = {}) {
181
181
  'validation caller is missing the Validation job; the Validation / Gate aggregate check cannot form.'
182
182
  )
183
183
  }
184
- if (!/types:\n\s+- ready_for_review/.test(caller)) {
185
- error('validation caller must wait for the ready_for_review transition.')
184
+ if (
185
+ !/types:\n\s+- ready_for_review\n\s+- synchronize/.test(caller) ||
186
+ !/github\.event\.pull_request\.draft == false/.test(caller)
187
+ ) {
188
+ error(
189
+ 'validation caller must run for ready pull requests and their new commits while ignoring drafts.'
190
+ )
186
191
  }
187
- if (/\s+- (?:opened|synchronize|reopened|converted_to_draft)\s*$/.test(caller)) {
192
+ if (/^\s+- (?:opened|reopened|converted_to_draft)\s*$/m.test(caller)) {
188
193
  error('validation caller registers draft-time checks; run code-foundry sync.')
189
194
  }
190
195
  if (