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.
- package/.github/workflows/cloudflare-delivery.yml +354 -0
- package/.github/workflows/cloudflare-deploy.yml +90 -47
- package/CHANGELOG.md +7 -0
- package/docs/CONFIGURATION.md +17 -9
- package/docs/cloudflare-delivery.md +188 -0
- package/package.json +1 -1
- package/src/commands/cloudflare-delivery.mjs +351 -0
- package/src/lib/cloudflare-delivery.mjs +233 -0
|
@@ -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.
|
|
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
|
+
}
|