code-foundry 1.11.0 → 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,188 @@
1
+ # Verified Cloudflare delivery
2
+
3
+ The opt-in `cloudflare-delivery.yml` workflow builds and uploads a candidate,
4
+ validates its actual version bindings, runs an HTTP smoke probe and the required
5
+ repository-owned verification command, then promotes **that version ID** after
6
+ GitHub environment approval. Production promotion uses the Cloudflare deployment
7
+ API and never rebuilds the application. The existing `cloudflare-deploy.yml`
8
+ remains available for direct deployments and initial provisioning.
9
+
10
+ ## Adoption
11
+
12
+ Call the new reusable workflow using an immutable, reviewed 40-character Code
13
+ Foundry commit SHA and pass the same `runtime-ref`. Required inputs are
14
+ `worker-name`, `artifact-path`, and `verify-command`.
15
+
16
+ ```yaml
17
+ jobs:
18
+ delivery:
19
+ # Replace REVIEWED_SHA with the same reviewed 40-character SHA in both places.
20
+ uses: 0xPlayerOne/code-foundry/.github/workflows/cloudflare-delivery.yml@REVIEWED_SHA
21
+ permissions:
22
+ contents: read
23
+ deployments: write
24
+ with:
25
+ runtime-ref: REVIEWED_SHA
26
+ mode: production
27
+ worker-name: company-site
28
+ artifact-path: dist
29
+ verify-command: '["bun", "run", "test:deployed"]'
30
+ production-url: https://example.com
31
+ smoke-path: /health
32
+ canary-percentage: 10
33
+ canary-verify-command: '["bun", "run", "test:canary"]'
34
+ secrets:
35
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
36
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
37
+ ```
38
+
39
+ `artifact-path` is relative to `working-directory`; `install-working-directory`
40
+ selects the lockfile root. The selected output tree is hashed before and after
41
+ upload; changes during upload fail. Include all built code/assets in that tree
42
+ and disable duplicate custom build steps. This digest identifies the declared
43
+ local build tree, not a Cloudflare-signed digest of every uploaded configuration
44
+ field. The Worker version ID is the authoritative identity for promotion.
45
+
46
+ Wrangler defaults to the installed, lockfile-resolved version. Explicit overrides
47
+ must be exact versions, never `latest`, floating majors, or semver ranges. Pin a
48
+ Wrangler version supporting `WRANGLER_OUTPUT_FILE_PATH` and version-1
49
+ `version-upload` records. Missing preview URLs and output-schema mismatches fail.
50
+ Bun remains the installation path, consistent with the existing deploy workflow.
51
+
52
+ The repository verification command receives `BASE_URL` and
53
+ `FOUNDRY_DEPLOYMENT_PHASE` (`candidate`, `canary`, or `production`). It must check
54
+ critical journeys, redirects, assets, and application-specific behavior. The
55
+ built-in smoke probe requires a successful 2xx response and does not follow
56
+ redirects. Deploy credentials are not supplied to verification steps and are
57
+ removed from the verification child environment as defense in depth. Install any
58
+ browser binaries required by your verification command explicitly.
59
+
60
+ When `canary-percentage` is below 100, `canary-verify-command` is required and
61
+ must prove that the live request served the candidate rather than the baseline.
62
+ It receives `FOUNDRY_EXPECTED_VERSION_ID`, `FOUNDRY_DEPLOYMENT_ID`, and
63
+ `FOUNDRY_CANARY_PERCENTAGE`; use a version-aware response/header or an equivalent
64
+ application check. A command that only observes a successful production URL is
65
+ not sufficient.
66
+
67
+ ## Approvals, ordering, and evidence
68
+
69
+ Create and protect the fixed `Preview` and `Production` GitHub environments
70
+ before adoption. The workflow references them at the **job** level; naming an
71
+ environment does not itself configure reviewers or branch restrictions. Configure
72
+ required reviewers and branch restrictions on `Production` separately. Production
73
+ execution requires the current default-branch commit and
74
+ rechecks freshness after approval and before final promotion. Fork PRs and
75
+ `pull_request_target` execution are excluded.
76
+
77
+ Per-repository/Worker/mode concurrency never cancels a running promotion. GitHub
78
+ concurrency is not a FIFO queue; stale-source rejection is still necessary.
79
+ Other deployment systems and dashboard edits are outside this lock. The helper
80
+ also checks that its deployment has not been replaced before a subsequent
81
+ promotion or rollback, but the Cloudflare API check/write is not an atomic CAS.
82
+ Migrate one application to one deployment owner; do not leave competing legacy
83
+ and new production workflows active.
84
+
85
+ Outputs include preview URL, exact version ID, source SHA, declared build-tree
86
+ SHA-256 digest, Cloudflare deployment ID, and GitHub application deployment ID.
87
+ Explicit application deployment records receive in-progress and success/failure
88
+ statuses, in addition to GitHub's environment-job records. Sanitized candidate and
89
+ production JSON evidence is uploaded even on failure. Binding values, API bodies,
90
+ and credentials are not copied into those reports. Forced runner termination may
91
+ prevent final status steps; the GitHub job still reflects cancellation/failure.
92
+
93
+ ## Stateful resources and isolation
94
+
95
+ Without a policy file, non-resource bindings (for example strings, secrets, and
96
+ static assets) are allowed; resource/service bindings fail closed. The uploaded
97
+ version's actual bindings are inspected through the Cloudflare API. Durable
98
+ Object bindings or exports are rejected: use a repository-specific migration and
99
+ preview workflow instead of pretending version preview supports that topology.
100
+
101
+ A reviewed read-only policy can allow smoke testing a candidate that shares
102
+ production resources:
103
+
104
+ ```json
105
+ {
106
+ "schemaVersion": 1,
107
+ "readOnlyBindings": [{ "name": "DB", "type": "d1" }],
108
+ "rollbackSafe": false
109
+ }
110
+ ```
111
+
112
+ Pass its relative path as `binding-policy-file`. This declaration is **not a
113
+ sandbox**: the repository owner must ensure its test routes cannot mutate shared
114
+ data. A version preview does not automatically create isolated KV, R2, D1, or
115
+ service resources, and a GET route can still have side effects.
116
+
117
+ For separately provisioned preview Workers, `isolatedBindings` entries can compare
118
+ the actual binding's resource identifiers with expected preview identifiers and
119
+ assert that they differ from declared production identifiers:
120
+
121
+ ```json
122
+ {
123
+ "schemaVersion": 1,
124
+ "isolatedBindings": [
125
+ {
126
+ "name": "DB",
127
+ "type": "d1",
128
+ "expected": { "id": "preview-database-id" },
129
+ "production": { "id": "production-database-id" }
130
+ }
131
+ ]
132
+ }
133
+ ```
134
+
135
+ Use the field names returned for that binding type by the Version API. Resource
136
+ provisioning and the correctness of production-identifier declarations remain
137
+ repository-owned. Isolated preview bindings are rejected in production mode to
138
+ prevent accidentally promoting staging resources. A version from a different
139
+ Worker cannot be promoted across Workers; production mode uploads and tests its
140
+ own candidate on the production Worker with reviewed read-only tests.
141
+
142
+ ## Canary and rollback policy
143
+
144
+ A percentage below 100 first routes that share to the candidate, verifies it,
145
+ and then promotes the same version to 100 and verifies again. A canary requires
146
+ one prior baseline serving 100% and the explicit `canary-verify-command` above.
147
+ A single load-balanced HTTP request may hit the old version; the command must
148
+ observe the expected version ID. Set `canary-percentage: 100` when that
149
+ version-aware observability is not configured.
150
+
151
+ Automatic rollback is off. Enabling `auto-rollback` also requires an explicit
152
+ `rollbackSafe: true` policy and both current and prior versions to be free of
153
+ stateful bindings/DO exports. The policy must additionally account for external
154
+ side effects that binding inspection cannot detect. Rollback restores the exact
155
+ previous traffic split only when this run's deployment is still current. Failure
156
+ remains a failed deployment even after rollback. Resource data, migrations,
157
+ external API side effects, routes, and non-versioned settings are never rewound.
158
+ The evidence file records prior versions for manual recovery when automation is
159
+ unsafe or unavailable. Recovery itself must be verified operationally.
160
+
161
+ This workflow targets already-provisioned Workers with version previews enabled.
162
+ Manage initial Worker/route/trigger setup and non-versioned settings separately;
163
+ the deployment API intentionally changes version routing only.
164
+
165
+ ## Legacy workflow hardening
166
+
167
+ `cloudflare-deploy.yml` now uses real job environments, non-cancelling concurrency,
168
+ structured Wrangler output, exact/local Wrangler selection, reusable outputs, and
169
+ in-progress/failure deployment records. Its legacy-compatible default remains
170
+ `latest`; callers should prefer `local` or an exact version for reproducibility. A
171
+ production URL can be supplied with `deployment-url` when API output contains only
172
+ route patterns. It is still a **direct, unverified deployment**; adopt
173
+ `cloudflare-delivery.yml` for candidate verification and guarded promotion.
174
+
175
+ ## References and testing
176
+
177
+ The implementation follows Cloudflare's structured Wrangler output and version
178
+ routing APIs:
179
+
180
+ - https://developers.cloudflare.com/workers/wrangler/system-environment-variables/
181
+ - https://developers.cloudflare.com/workers/versions-and-deployments/
182
+ - https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/versions/methods/get/
183
+ - https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/deployments/methods/create/
184
+
185
+ Run `node --test test/cloudflare-delivery.test.mjs`. Tests use deterministic API
186
+ responses and never deploy a Worker. Before production rollout, run the new
187
+ workflow against a disposable Worker and protected test environments, including
188
+ smoke failure and rollback scenarios.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
6
6
  "bugs": {
@@ -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
+ }