scripts/deploy-with-epilogue.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* `npm run deploy` — build the current source, run wrangler deploy, then print
* a next-steps epilogue. Pass `--skip-build` only when a caller has just run
* `npm run build` and wants to reuse that exact artifact.
*
* Workers Builds streams the build and deploy steps into one log that ends,
* without this, at wrangler's own output: a raw workers.dev URL and no hint
* that /admin is the next stop. Wrangler 4.x has no command that reports the
* account's workers.dev subdomain, but `wrangler deploy` prints the deployed
* URL on success — so tee its stdout, grep the URL, and append instructions.
*
* The epilogue is additive: wrangler's output passes through untouched, a
* non-zero exit propagates unchanged with no epilogue (never dress up a
* failed deploy), and stdout is scanned line-by-line rather than buffered.
*/
import { spawn, spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
existsSync,
lstatSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { hasScheduledComposition } from './worker-artifact.mjs';
import { builtWorkerConfigPath } from './lib/built-worker-config.mjs';
import { mergeDeploymentSecrets, OPERATOR_SECRETS_ENV, readOperatorSecretsFile } from './lib/deploy-operator-secrets.mjs';
import { isQaLane } from './lib/qa-lanes.mjs';
import { describeLaneSecrets, ensureLaneSeedToken, LANE_SEED_TOKEN_BINDING, resolveLaneSecrets } from './lib/lane-secrets.mjs';
import { wranglerInspector, deploymentFingerprint } from './lib/inspect-deployment.mjs';
import { AUTH_SCHEMA_QUERY, expectedAuthSchema, normalizeAuthSchemaRows } from './lib/auth-schema.mjs';
import { validateInstallation, validateTarget, assertSameInstallation, overlayInstallation, wranglerProfileArgs } from './lib/upgrade-installation.mjs';
import { readPrivateJson, writePrivateJson, writeDeploymentEvent } from './lib/upgrade-receipt.mjs';
import { verifyRetainedBuildRoot } from './lib/upgrade-source.mjs';
import { preflightCloudflareAccount, assertCloudflareAccountConfig } from './lib/cloudflare-account-preflight.mjs';
import {
formatPreflightProblems,
prebuildSandboxImage,
preflightSandboxDeployment,
rerunCommand,
sandboxApplicationName,
sandboxPartialDeployRecovery,
sandboxStoppedBeforeUpload,
uploadedBeforeFailure,
useSandboxImage,
checkpointsOffNotice,
withoutCheckpointBucket,
verifySandboxContainerApplication,
} from './lib/sandbox-deploy-preflight.mjs';
import {
classifyCloudflareDeploymentProfile,
formatCloudflareDeploymentTargetTuple,
readCloudflareDeploymentTargetTuple,
resolveCloudflareDeploymentProfile,
} from './cloudflare-deployment-profile.mjs';
import {
mintSetupCapability,
SETUP_CAPABILITY_DIGEST_BINDING,
SETUP_CAPABILITY_ISSUED_AT_BINDING,
setupCapabilityUrl,
} from '../src/auth/setup-capability.mjs';
import {
DEPLOYMENT_ACTIVATION_DIGEST_BINDING,
DEPLOYMENT_ACTIVATION_ISSUED_AT_BINDING,
mintDeploymentActivation,
} from '../src/auth/deployment-activation.mjs';
import { parseResidencyHoldMs, RESIDENCY_HOLD_VARIABLE } from '../src/state/residency-hold.ts';
const runnerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// Invoke wrangler's bin with the current node (mirrors flue-build-cf.mjs):
// use the current runner's reviewed deployment tooling, including recovery.
const wranglerBin = path.join(runnerRoot, 'node_modules', 'wrangler', 'bin', 'wrangler.js');
const cliArgs = process.argv.slice(2);
// `--worktree <path>` deploys another checkout's candidate through that
// checkout's own reviewed wrapper, from that checkout, so a verifier that
// batches repairs in a sibling worktree runs one plain command instead of
// `cd <worktree> && ...`. The flag never applies here; the target's script
// sees the remaining arguments and the same environment.
{
const index = cliArgs.findIndex((arg) => arg === '--worktree' || arg.startsWith('--worktree='));
if (index >= 0) {
const inline = cliArgs[index].startsWith('--worktree=');
const requested = inline ? cliArgs[index].slice('--worktree='.length) : cliArgs[index + 1];
const consumed = inline ? 1 : 2;
if (!requested || requested.startsWith('--')) { console.error('--worktree needs an absolute checkout path.'); process.exit(2); }
if (!path.isAbsolute(requested)) { console.error(`--worktree must be absolute, not ${requested}.`); process.exit(2); }
const target = path.resolve(requested);
const script = path.join(target, 'scripts', 'deploy-with-epilogue.mjs');
if (!existsSync(script)) { console.error(`${target} is not a Chickpea checkout (no scripts/deploy-with-epilogue.mjs).`); process.exit(2); }
cliArgs.splice(index, consumed);
if (realpathSync(target) !== realpathSync(runnerRoot)) {
const forwarded = spawnSync(process.execPath, [script, ...cliArgs], { cwd: target, stdio: 'inherit', env: process.env });
if (forwarded.error) console.error(`Could not run ${script}: ${forwarded.error.message}`);
if (forwarded.signal) process.kill(process.pid, forwarded.signal);
process.exit(forwarded.status ?? 1);
}
}
}
// `--release-tag vX.Y.Z` admits a published release to a QA lane after main
// has moved past it: HEAD must be exactly that tag and the tag must be on main.
let releaseTagOption;
{
const index = cliArgs.findIndex((arg) => arg === '--release-tag' || arg.startsWith('--release-tag='));
if (index >= 0) {
const inline = cliArgs[index].startsWith('--release-tag=');
releaseTagOption = inline ? cliArgs[index].slice('--release-tag='.length) : cliArgs[index + 1];
if (!releaseTagOption || releaseTagOption.startsWith('--')) { console.error('--release-tag needs a tag such as v0.1.33.'); process.exit(2); }
cliArgs.splice(index, inline ? 1 : 2);
}
}
const deployArgs = cliArgs.filter((arg) => !['--skip-build', '--preflight-only'].includes(arg));
const skipBuild = cliArgs.includes('--skip-build');
const preflightOnly = cliArgs.includes('--preflight-only');
const upgradeContextPath = process.env.CHICKPEA_UPGRADE_CONTEXT;
let upgradeContext;
let projectRoot = runnerRoot;
try {
if (upgradeContextPath) {
upgradeContext = readPrivateJson(upgradeContextPath);
if (upgradeContext.sourceRoot !== undefined) {
if (!skipBuild) throw new Error('A retained upgrade artifact must already be built.');
projectRoot = verifyRetainedBuildRoot(upgradeContextPath, upgradeContext);
}
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
// A QA checkout must never fall through to the ordinary production target.
// This is a selection check, not claim authority; the environment preflight
// below still validates the actual lease, source, and immutable identities.
//
// `CHICKPEA_DEPLOY_TARGET=production` names the ordinary wrangler.jsonc Worker
// on purpose. It is consumed here so the build profile, which knows only the
// claimed lanes, still sees an unset target for that deploy.
const PRODUCTION_DEPLOY_TARGET = 'production';
// Mirrors defaultEnvironmentRoot() in scripts/lib/environment-registry.mjs;
// inlined so the ordinary production deploy still loads no lane module.
const claimedLaneRegistryRoot = process.env.CHICKPEA_ENVIRONMENT_ROOT?.trim()
|| path.join(homedir(), '.chickpea', 'environments');
const explicitProductionTarget = process.env.CHICKPEA_DEPLOY_TARGET?.trim() === PRODUCTION_DEPLOY_TARGET;
if (explicitProductionTarget) delete process.env.CHICKPEA_DEPLOY_TARGET;
const requestedDeploymentTarget = process.env.CHICKPEA_DEPLOY_TARGET?.trim();
const installationLane = process.env.CHICKPEA_INSTALLATION_LANE?.trim();
let installationApi;
try {
if (installationLane) {
if (!explicitProductionTarget || requestedDeploymentTarget) throw new Error('A borrowed installation uses the ordinary explicitly selected Worker in its isolated installation checkout.');
installationApi = await import('./lib/environment-installation.mjs');
installationApi.assertInstallationDeployment(installationLane, projectRoot);
}
const markerPath = path.join(projectRoot, '.chickpea-environment');
let markerStat;
try { markerStat = lstatSync(markerPath); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (markerStat) {
if (!markerStat.isFile() || markerStat.isSymbolicLink()) {
throw new Error('QA claim marker is not a regular file. Refusing deployment.');
}
let marker;
try { marker = JSON.parse(readFileSync(markerPath, 'utf8')); } catch {
throw new Error('QA claim marker is unreadable. Refusing deployment.');
}
if (marker?.schemaVersion !== 'chickpea-environment-claim/v1'
|| !isQaLane(marker.target)) {
throw new Error('QA claim marker is invalid. Refusing deployment.');
}
if (requestedDeploymentTarget !== marker.target) {
throw new Error(`This worktree claims ${marker.target}. Set CHICKPEA_DEPLOY_TARGET=${marker.target}; refusing an unspecified or different target.`);
}
}
if (!requestedDeploymentTarget && (process.env.CHICKPEA_LOCAL_LANE
|| existsSync(path.join(projectRoot, '.chickpea-local-worker')))) {
throw new Error('This checkout owns a local Worker lane. Select an explicitly claimed QA deployment target; the default production deployment is refused.');
}
// A machine that operates claimed QA lanes keeps a registry under
// ~/.chickpea/environments. On such a machine an unnamed deploy is far more
// often a checkout that forgot its lane than a deliberate production push,
// so the ordinary Worker must be named explicitly. Self-hosters, Workers
// Builds, and non-mutating dry runs have no registry and are unaffected.
const mutatingDeploy = !deployArgs.includes('--dry-run') && !preflightOnly;
if (!requestedDeploymentTarget && !explicitProductionTarget && mutatingDeploy
&& process.env.WORKERS_CI !== '1' && existsSync(claimedLaneRegistryRoot)) {
throw new Error(
'This machine operates claimed QA lanes, so an unnamed deploy is refused. ' +
'Set CHICKPEA_DEPLOY_TARGET=amber, cobalt, or violet for a claimed lane, or ' +
`CHICKPEA_DEPLOY_TARGET=${PRODUCTION_DEPLOY_TARGET} to deploy the ordinary wrangler.jsonc Worker on purpose.`,
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (
isQaLane(requestedDeploymentTarget) &&
cliVariable('CHICKPEA_TELEMETRY_ENVIRONMENT') !== undefined
) {
console.error(
'Do not override CHICKPEA_TELEMETRY_ENVIRONMENT for a QA deployment target; ' +
'the reviewed artifact must supply the canonical test label.',
);
process.exit(1);
}
// Workers Builds runs its configured build command immediately before its
// configured deploy command in the same build workspace. Reuse that exact
// artifact, but only when both Workers-specific markers are present; local
// deploys and generic CI retain the build-before-deploy safety boundary.
const reuseWorkersBuildArtifact =
process.env.WORKERS_CI === '1' &&
typeof process.env.WORKERS_CI_BUILD_UUID === 'string' &&
process.env.WORKERS_CI_BUILD_UUID.trim().length > 0;
let deploymentProfile;
try {
// Resolve once so build, preflight, D1 setup, and deploy cannot disagree if
// a caller mutates process.env later in this process.
deploymentProfile = resolveCloudflareDeploymentProfile();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
// An unset profile means core, but only an explicit `core` may replace a live
// coding sandbox: a routine update must not silently uninstall it.
const explicitCoreProfile = process.env.CHICKPEA_DEPLOY_PROFILE?.trim() === 'core';
// Local sandbox deploys prove Docker, the base image, and Containers access,
// then push the image, all before the Worker upload that makes the SANDBOX
// binding live. Workers Builds keeps Wrangler's own container build; a
// `--containers-rollout=none` deploy deliberately skips the Container step.
const guardedSandboxDeploy = deploymentProfile === 'sandbox' &&
!preflightOnly &&
!deployArgs.some((arg) => ['--dry-run', '--help', '-h'].includes(arg)) &&
!deployArgs.some((arg) => arg === '--containers-rollout=none' || arg === '--containers-rollout') &&
process.env.WORKERS_CI !== '1' &&
!upgradeContextPath;
const BETA_FLUE_CLASSES = Object.freeze([
'FlueRegistry',
'FlueSlackThreadAgent',
'FlueRoutineIntentAgent',
'FlueRoutineWorkflow',
]);
const RETIRED_AUTH_CLASSES = Object.freeze(['AuthGuard']);
const EXPECTED_DELETED_CLASSES = Object.freeze([
...BETA_FLUE_CLASSES,
...RETIRED_AUTH_CLASSES,
]);
const V2_AGENT_CLASSES = Object.freeze([
'FlueChickpeaSlackV2Agent',
'FlueChickpeaRoutineIntentV2Agent',
'FlueChickpeaRoutineExecutionV2Agent',
]);
const V2_AGENT_BINDINGS = Object.freeze([
['FLUE_CHICKPEA_SLACK_V2_AGENT', 'FlueChickpeaSlackV2Agent'],
['FLUE_CHICKPEA_ROUTINE_INTENT_V2_AGENT', 'FlueChickpeaRoutineIntentV2Agent'],
['FLUE_CHICKPEA_ROUTINE_EXECUTION_V2_AGENT', 'FlueChickpeaRoutineExecutionV2Agent'],
]);
const PROTECTED_CLASSES = new Set(['TagStateStore', 'Sandbox', 'ContainerProxy']);
function hasCustomConfigFlag(args) {
return args.some((argument) =>
argument === '--config' || argument === '-c' || argument.startsWith('--config=')
);
}
function hasWorkerNameOverride(args) {
return args.some((argument) => argument === '--name' || argument.startsWith('--name='));
}
if (hasCustomConfigFlag(deployArgs)) {
console.error(
'Do not pass a custom Wrangler config. Build the Vite artifact and use its generated deploy redirect.',
);
process.exit(1);
}
if (hasWorkerNameOverride(deployArgs)) {
console.error(
'Do not override the Worker name. Chickpea must inspect and mutate the same Worker it deploys.',
);
process.exit(1);
}
function validateAgentViewManifest() {
const manifestPath = path.join(projectRoot, 'slack-app-manifest.json');
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to validate the Slack manifest before deployment: ${detail}`);
}
const hasAgentView = Boolean(manifest?.features?.agent_view);
const hasAssistantView = Boolean(manifest?.features?.assistant_view);
if (hasAgentView && hasAssistantView) {
throw new Error(
'Slack manifest is invalid for deployment: agent_view and assistant_view cannot coexist.',
);
}
if (!hasAgentView) {
throw new Error(
'Slack manifest requires features.agent_view as the permanent app-home contract.',
);
}
}
try {
validateAgentViewManifest();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
// A selected Phase 1 lane is a permanent, claimed environment. Resolve that
// authority before even building so an expired/rotated claim or stale HEAD
// cannot cause a build hook (or anything after it) to run. The ordinary
// production deploy does not load this module when no lane was selected and
// the checkout has no QA ownership marker, as checked above.
const selectedEnvironmentTarget = !deployArgs.includes('--dry-run')
&& requestedDeploymentTarget
? requestedDeploymentTarget
: undefined;
if (releaseTagOption !== undefined && !selectedEnvironmentTarget) {
console.error('--release-tag applies only to a QA lane deploy: set CHICKPEA_DEPLOY_TARGET=<lane> and omit --dry-run.');
process.exit(2);
}
let environmentPreflightApi;
let initialEnvironmentPreflight;
let resumedEnvironmentDeployment;
let environmentMutationLease;
let qaCandidateApi;
let qaSourceAdmission;
if (selectedEnvironmentTarget) {
try {
// Admission is deliberately outside the nonce-bound intent/receipt schema.
// Every upload, including a resumed upload, needs it. The separate env
// reconciliation command can finish old intents without changing source.
qaCandidateApi = await import('./lib/qa-candidate.mjs');
qaSourceAdmission = qaCandidateApi.admitQaCandidate(projectRoot, releaseTagOption === undefined ? {} : { releaseTag: releaseTagOption });
process.stdout.write(`QA source includes remote main ${qaSourceAdmission.approvedTip}; tracking ref ${qaSourceAdmission.trackingMatchesRemote ? 'matches' : 'differs (use the admitted tip as --base)'}.\n`);
environmentPreflightApi = await import('./lib/environment-preflight.mjs');
resumedEnvironmentDeployment = await environmentPreflightApi.resumeEnvironmentDeployment(
selectedEnvironmentTarget,
{ projectRoot, providerContext: deploymentResourceArgs() },
);
initialEnvironmentPreflight = resumedEnvironmentDeployment?.preflight
?? await environmentPreflightApi.preflightEnvironmentMutation(
selectedEnvironmentTarget,
{ projectRoot, providerContext: deploymentResourceArgs() },
);
// The claimed lane's immutable AUTH_DB and schema generation are already
// known from the registry the preflight just validated. Feed them to the
// build profile so a claimed deploy does not depend on the operator
// exporting CHICKPEA_DEPLOY_AUTH_DB_ID and CHICKPEA_DEPLOY_SCHEMA_GENERATION
// by hand; explicit values still win and are still checked below.
const metadata = initialEnvironmentPreflight.deploymentMetadata;
if (selectedEnvironmentTarget === 'violet') {
const registration = initialEnvironmentPreflight.registration;
for (const [key, value] of Object.entries({
CHICKPEA_DEPLOY_WORKER_NAME: registration.workerName,
CHICKPEA_DEPLOY_AUTH_DB_NAME: registration.authDatabaseName,
})) {
if (process.env[key]?.trim() && process.env[key].trim() !== value) {
throw new Error(`${key} does not match the claimed Violet installation.`);
}
process.env[key] = value;
}
}
const resolved = [];
if (!process.env.CHICKPEA_DEPLOY_AUTH_DB_ID?.trim() && metadata?.authDatabaseId) {
process.env.CHICKPEA_DEPLOY_AUTH_DB_ID = metadata.authDatabaseId;
resolved.push('AUTH_DB id');
}
if (!process.env.CHICKPEA_DEPLOY_SCHEMA_GENERATION?.trim() && metadata?.schemaGeneration) {
process.env.CHICKPEA_DEPLOY_SCHEMA_GENERATION = metadata.schemaGeneration;
resolved.push('schema generation');
}
if (resolved.length > 0) {
process.stdout.write(
`Resolved ${resolved.join(' and ')} for ${selectedEnvironmentTarget} from the claimed registration.\n`,
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
function buildCloudflareArtifact() {
process.stdout.write('Building the Cloudflare artifact from current source...\n');
const npmExecPath = process.env.npm_execpath;
const buildCommand = npmExecPath
? [process.execPath, [npmExecPath, 'run', 'build']]
: [process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build']];
const build = spawnSync(buildCommand[0], buildCommand[1], {
cwd: projectRoot,
stdio: 'inherit',
});
if (build.error) {
console.error(`Unable to start the Cloudflare build: ${build.error.message}`);
process.exit(1);
}
if (build.status !== 0) {
process.exit(build.status ?? 1);
}
}
const checkAccount = !preflightOnly && !deployArgs.some((arg) => ['--dry-run', '--help', '-h'].includes(arg));
let checkedAccount;
function accountCheckOptions(configPath) {
return { runnerRoot, projectRoot, configPath, providerContext: deploymentResourceArgs(),
...(upgradeContext ? { expectedAccount: upgradeContext.target?.account } : {}) };
}
async function checkDeploymentAccount(configPath) {
const result = await preflightCloudflareAccount(accountCheckOptions(configPath));
// Pin an unambiguous whoami selection so later Wrangler calls cannot choose
// another account. This affects only the current guarded deploy process.
if (result.accountId) process.env.CLOUDFLARE_ACCOUNT_ID = result.accountId;
return result;
}
try {
if (checkAccount && !skipBuild && !reuseWorkersBuildArtifact) {
checkedAccount = await checkDeploymentAccount(path.join(projectRoot, 'wrangler.jsonc'));
}
} catch (error) {
console.error(error.message);
process.exit(1);
}
function sandboxToolOptions(configPath) {
return {
wranglerBin,
projectRoot,
configPath,
providerContext: deploymentResourceArgs(),
dockerfilePath: path.join(projectRoot, 'Dockerfile'),
accountId: checkedAccount?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID,
...(process.env.DEPLOY_TEST_SANDBOX_RETRY_MS ? { retryDelayMs: Number(process.env.DEPLOY_TEST_SANDBOX_RETRY_MS) } : {}),
};
}
// An account without R2 cannot hold the checkpoint bucket. Checkpoints are
// optional, so the deploy drops that binding instead of failing mid-deploy.
let sandboxCheckpointsOff = false;
if (guardedSandboxDeploy) {
try {
const { problems, checkpoints } = preflightSandboxDeployment(sandboxToolOptions(path.join(projectRoot, 'wrangler.jsonc')));
sandboxCheckpointsOff = !checkpoints;
if (problems.length) {
console.error(formatPreflightProblems(problems));
process.exit(1);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// A claimed QA lane gets two cheap checks before the build: dependencies that
// match the lockfile (a stale install builds a candidate nobody reviewed), and
// a deploy profile that matches the live Worker (a core deploy over a sandbox
// lane is refused after the build anyway; refuse it before spending one).
if (selectedEnvironmentTarget && !skipBuild && !reuseWorkersBuildArtifact) {
try {
const { lockfileDrift, staleDependenciesMessage } = await import('./lib/installed-dependencies.mjs');
const drift = lockfileDrift(projectRoot);
if (drift.length) throw new Error(staleDependenciesMessage(drift, 'Nothing was built or changed.'));
const workerName = initialEnvironmentPreflight.registration.workerName;
if (deploymentProfile === 'core' && !explicitCoreProfile) {
const { readLiveWorkerProfile } = await import('./lib/environment-capabilities.mjs');
const live = await readLiveWorkerProfile(workerName, { providerContext: deploymentResourceArgs() });
if (live.profile === 'unknown') {
process.stdout.write(`! Could not read ${workerName}'s live deploy profile (${live.errors.join(', ') || 'no answer'}); ` +
'the post-build check still refuses a core deploy over a sandbox Worker.\n');
}
if (live.profile === 'sandbox' || live.profile === 'mixed') {
throw new Error(
`Claimed lane ${selectedEnvironmentTarget} serves the sandbox profile, and this command would deploy the core profile. ` +
`Run \`CHICKPEA_DEPLOY_TARGET=${selectedEnvironmentTarget} npm run verify:host -- --wait-ms 300000 npm run deploy:sandbox\` instead. ` +
'Nothing was built or changed.',
);
}
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
if (!skipBuild && !reuseWorkersBuildArtifact) buildCloudflareArtifact();
function sortedUnique(values) {
return [...new Set(values)].sort();
}
function sameMembers(actual, expected) {
return actual.length === expected.length &&
JSON.stringify([...actual].sort()) === JSON.stringify([...expected].sort());
}
function renamedClassNames(migrations) {
const names = [];
for (const migration of migrations) {
for (const rename of migration.renamed_classes ?? []) {
if (typeof rename?.from === 'string') names.push(rename.from);
if (typeof rename?.to === 'string') names.push(rename.to);
}
}
return names;
}
function requireBuiltArtifact() {
const configPath = builtWorkerConfigPath(projectRoot);
const config = JSON.parse(readFileSync(configPath, 'utf8'));
const artifactRoot = path.dirname(configPath);
const bundlePath = path.resolve(artifactRoot, config.main ?? 'index.js');
const bundle = existsSync(bundlePath)
? readdirSync(artifactRoot, { recursive: true })
.filter((entry) => typeof entry === 'string' && entry.endsWith('.js'))
.sort()
.map((entry) => readFileSync(path.join(artifactRoot, entry), 'utf8'))
.join('\n')
: '';
return { configPath, config, bundle, entry: existsSync(bundlePath) ? readFileSync(bundlePath, 'utf8') : '' };
}
function expectedWorkerName(targetTuple) {
if (targetTuple) return targetTuple.workerName;
const override = process.env.WRANGLER_CI_OVERRIDE_NAME?.trim();
if (override) return override;
try {
const source = readFileSync(path.join(projectRoot, 'wrangler.jsonc'), 'utf8');
const match = source.match(/"name"\s*:\s*"([^"]+)"/);
if (match?.[1]) return match[1];
} catch {
/* the generated artifact validation below will report the missing identity */
}
return undefined;
}
function validateArtifactIdentity(artifact, { requireDatabaseId = false } = {}) {
const { config, configPath } = artifact;
if (installationLane) {
const database = config.d1_databases?.find((binding) => binding.binding === 'AUTH_DB');
installationApi.assertInstallationDeployment(installationLane, projectRoot, {
workerName: config.name, authDatabaseName: database?.database_name, authDatabaseId: database?.database_id,
});
}
const failures = [];
const targetTuple = readCloudflareDeploymentTargetTuple(config);
const expectedName = expectedWorkerName(targetTuple);
if (typeof config.name !== 'string' || !config.name.trim()) {
failures.push('a generated Worker name');
} else if (expectedName && config.name !== expectedName) {
failures.push(`Worker identity ${expectedName} (found ${config.name})`);
}
if (
typeof config.topLevelName === 'string' &&
config.topLevelName !== config.name
) {
failures.push(`one Worker identity (name=${config.name}, topLevelName=${config.topLevelName})`);
}
const main = typeof config.main === 'string'
? path.resolve(path.dirname(configPath), config.main)
: undefined;
if (!main || !existsSync(main)) failures.push('a real generated Worker entry');
const authDatabases = (config.d1_databases ?? []).filter(
(binding) => binding?.binding === 'AUTH_DB',
);
if (authDatabases.length !== 1) {
failures.push('exactly one AUTH_DB binding');
} else {
const authDb = authDatabases[0];
if (typeof authDb.database_name !== 'string' || !authDb.database_name.trim()) {
failures.push('an AUTH_DB database name');
}
if (!String(authDb.migrations_dir ?? '').endsWith('migrations/better-auth')) {
failures.push('AUTH_DB with reviewed Better Auth migrations');
}
if (
requireDatabaseId &&
(typeof authDb.database_id !== 'string' || !authDb.database_id.trim())
) {
failures.push('the resolved AUTH_DB database identity');
}
}
if (failures.length) {
throw new Error(`Cloudflare deployment identity preflight failed; missing or unsafe ${failures.join(', ')}.`);
}
}
function validateDeploymentProfile(artifact) {
let actualProfile;
try {
actualProfile = classifyCloudflareDeploymentProfile(artifact.config);
} catch (error) {
throw new Error(
`Cloudflare ${deploymentProfile} profile preflight failed: ` +
(error instanceof Error ? error.message : String(error)),
);
}
if (actualProfile !== deploymentProfile) {
throw new Error(
`Cloudflare deployment profile mismatch: requested ${deploymentProfile}, generated ${actualProfile}.`,
);
}
}
function validateFlue2CutoverArtifact(artifact) {
const { config, bundle } = artifact;
const failures = [];
const migrations = config.migrations ?? [];
const deleted = migrations.flatMap((migration) => migration.deleted_classes ?? []);
const renamed = renamedClassNames(migrations);
const destructive = sortedUnique([...deleted, ...renamed]);
const unexpected = destructive.filter((name) => !EXPECTED_DELETED_CLASSES.includes(name));
if (unexpected.length) failures.push(`unexpected deleted/renamed classes: ${unexpected.join(', ')}`);
const protectedDestruction = destructive.filter((name) => PROTECTED_CLASSES.has(name));
if (protectedDestruction.length) {
failures.push(`protected classes marked deleted/renamed: ${protectedDestruction.join(', ')}`);
}
if (!sameMembers(deleted, EXPECTED_DELETED_CLASSES) || renamed.length > 0) {
failures.push('the exact beta and retired AuthGuard deletion set with no class renames');
}
const reset = migrations.find((migration) => migration.tag === 'v6');
if (!reset || !sameMembers(reset.new_sqlite_classes ?? [], V2_AGENT_CLASSES)) {
failures.push('v6 fresh Flue 2 SQLite agent classes');
}
const sandboxMigration = migrations.find((migration) => migration.tag === 'v3');
if (!sandboxMigration || !sameMembers(sandboxMigration.new_sqlite_classes ?? [], ['Sandbox'])) {
failures.push('v3 Sandbox SQLite class');
}
const authGuardMigration = migrations.find((migration) => migration.tag === 'v7');
if (!authGuardMigration || !sameMembers(authGuardMigration.new_sqlite_classes ?? [], RETIRED_AUTH_CLASSES)) {
failures.push('v7 AuthGuard SQLite class history');
}
const authGuardRetirement = migrations.find((migration) => migration.tag === 'v8');
if (!authGuardRetirement || !sameMembers(authGuardRetirement.deleted_classes ?? [], RETIRED_AUTH_CLASSES)) {
failures.push('v8 AuthGuard retirement');
}
const gatewaySessionMigration = migrations.find((migration) => migration.tag === 'v9');
if (!gatewaySessionMigration || !sameMembers(gatewaySessionMigration.new_sqlite_classes ?? [], ['SlackGatewaySession'])) {
failures.push('v9 SlackGatewaySession SQLite class');
}
const threadRunnerMigration = migrations.find((migration) => migration.tag === 'v11');
if (!threadRunnerMigration || !sameMembers(threadRunnerMigration.new_sqlite_classes ?? [], ['SlackThreadRunner'])) {
failures.push('v11 SlackThreadRunner SQLite class');
}
const bindings = config.durable_objects?.bindings ?? [];
const hasBinding = (name, className) => bindings.some(
(binding) => binding.name === name && binding.class_name === className,
);
if (!hasBinding('TAG_STATE', 'TagStateStore')) failures.push('TAG_STATE/TagStateStore binding');
if (!hasBinding('SLACK_THREAD_RUNNER', 'SlackThreadRunner')) {
failures.push('SLACK_THREAD_RUNNER/SlackThreadRunner binding');
}
for (const [name, className] of V2_AGENT_BINDINGS) {
if (!hasBinding(name, className)) failures.push(`${name}/${className} binding`);
}
const betaBindings = bindings.filter((binding) => BETA_FLUE_CLASSES.includes(binding.class_name));
if (betaBindings.length) failures.push('no beta Flue Durable Object bindings');
if ((config.workflows ?? []).length !== 0) failures.push('no Flue workflow bindings');
const authDb = (config.d1_databases ?? []).find((binding) => binding.binding === 'AUTH_DB');
if (!authDb || !String(authDb.migrations_dir ?? '').endsWith('migrations/better-auth')) {
failures.push('AUTH_DB with reviewed Better Auth migrations');
}
if (config.observability?.traces?.enabled !== true) {
failures.push('enabled Workers Traces for metadata-only Flue spans');
}
if (!bundle.includes('@flue/runtime/cloudflare-tracing')) {
failures.push('explicit content-free Cloudflare tracing instrumentation');
}
if (!bundle.includes('FLUE_PRIVATE_SANDBOX_COMMAND_V1')) {
failures.push('content-free Cloudflare Sandbox exec logging');
}
if (!bundle.includes('chickpea.response-metadata')) {
failures.push('bounded metadata-only Chickpea instrumentation');
}
if (
typeof config.compatibility_date !== 'string' ||
config.compatibility_date < '2026-04-01'
) {
failures.push('compatibility_date at or above 2026-04-01');
}
if (!(config.compatibility_flags ?? []).includes('global_fetch_strictly_public')) {
failures.push('global_fetch_strictly_public for the shared Slack gateway');
}
if (config.version_metadata?.binding !== 'CF_VERSION_METADATA') {
failures.push('CF_VERSION_METADATA Worker version binding');
}
if (failures.length) {
throw new Error(`Flue 2 cutover preflight failed; missing or unsafe ${failures.join(', ')}.`);
}
}
function cliVariable(name) {
for (let index = 0; index < deployArgs.length; index += 1) {
const argument = deployArgs[index];
const raw = argument === '--var'
? deployArgs[index + 1]
: argument.startsWith('--var=')
? argument.slice(6)
: undefined;
if (typeof raw !== 'string') continue;
const separator = raw.search(/[:=]/);
if (separator < 1 || raw.slice(0, separator) !== name) continue;
return raw.slice(separator + 1);
}
return undefined;
}
function validateRoutineArtifact(artifact) {
const { config, bundle } = artifact;
const failures = [];
const crons = config.triggers?.crons ?? [];
if (crons.length !== 1 || crons[0] !== '* * * * *') failures.push('one * * * * * heartbeat Cron Trigger');
const bindings = config.durable_objects?.bindings ?? [];
if (!bindings.some((binding) => binding.name === 'TAG_STATE' && binding.class_name === 'TagStateStore')) {
failures.push('TAG_STATE/TagStateStore binding');
}
for (const [name, className] of V2_AGENT_BINDINGS.slice(1)) {
if (!bindings.some((binding) => binding.name === name && binding.class_name === className)) {
failures.push(`${name}/${className} binding`);
}
}
if (!hasScheduledComposition(artifact.entry)) {
failures.push('composed heartbeat and maintenance handlers');
}
if (
!bundle.includes('chickpea-routine-intent-v2') ||
!bundle.includes('chickpea-routine-execution-v2')
) {
failures.push('fresh Flue 2 routine agent registrations');
}
if (failures.length) {
throw new Error(
'Routine scheduling artifact is unsafe; missing ' + failures.join(', ') + '. ' +
'Repair the artifact and verify the heartbeat before deployment.',
);
}
}
function validateLedgerCanaryArtifact(artifact) {
const { config, bundle } = artifact;
const cliSelector = cliVariable('SLACK_TAG_LEDGER_CANARY_CHANNELS');
const selector = cliSelector ?? config.vars?.SLACK_TAG_LEDGER_CANARY_CHANNELS ?? '';
if (selector === '') return;
if (typeof selector !== 'string') {
throw new Error('SLACK_TAG_LEDGER_CANARY_CHANNELS must be a string.');
}
const entries = selector.split(',').map((entry) => entry.trim());
const exactPair = /^[A-Za-z][A-Za-z0-9_-]{1,63}\/[A-Za-z][A-Za-z0-9_-]{1,63}$/;
if (entries.length > 20 || entries.some((entry) => !exactPair.test(entry))) {
throw new Error(
'SLACK_TAG_LEDGER_CANARY_CHANNELS is unsafe: use 1-20 exact workspace/channel pairs ' +
'(for example T123/C456), comma-separated with no wildcard or empty entry.',
);
}
const requiredSeams = [
'SLACK_TAG_LEDGER_CANARY_CHANNELS',
'delivery_receipt_persist_unknown',
'slack_agent_bindings',
];
const missing = requiredSeams.filter((seam) => !bundle.includes(seam));
if (missing.length) {
throw new Error(
'SLACK_TAG_LEDGER_CANARY_CHANNELS is unsafe for this artifact; missing durable driver seams: ' +
missing.join(', ') + '. Deploy with the selector empty and repair the artifact.',
);
}
}
// Cloudflare turns run in per-thread SlackThreadRunner objects by default;
// SLACK_TAG_TURN_EXECUTOR=alarm is the emergency fallback to the state store's
// alarm. Accept only the two executors (or unset), and an explicit `runner`
// only for an artifact that carries the runner execution path. Unset needs no
// check: a Worker without the runner binding keeps turns on the alarm.
function validateTurnExecutorArtifact(artifact) {
const { config, bundle } = artifact;
const value = cliVariable('SLACK_TAG_TURN_EXECUTOR') ?? config.vars?.SLACK_TAG_TURN_EXECUTOR ?? '';
if (value === '' || value === 'alarm') return;
if (value !== 'runner') {
throw new Error(
'SLACK_TAG_TURN_EXECUTOR must be runner or alarm (the emergency fallback), or unset (runner).',
);
}
const bindings = config.durable_objects?.bindings ?? [];
const missing = ['SLACK_TAG_TURN_EXECUTOR', 'threadRunnerTurn', 'thread_runner_alarm']
.filter((seam) => !bundle.includes(seam));
if (!bindings.some((binding) =>
binding.name === 'SLACK_THREAD_RUNNER' && binding.class_name === 'SlackThreadRunner')) {
missing.push('SLACK_THREAD_RUNNER/SlackThreadRunner binding');
}
if (missing.length) {
throw new Error(
'SLACK_TAG_TURN_EXECUTOR=runner is unsafe for this artifact; missing thread runner seams: ' +
missing.join(', ') + '. Deploy with the variable unset and repair the artifact.',
);
}
}
// A Worker holds nothing for a residency hold window it cannot read, so refuse
// one here, where the operator sees why.
function validateResidencyHold(artifact) {
parseResidencyHoldMs(cliVariable(RESIDENCY_HOLD_VARIABLE) ?? artifact.config.vars?.[RESIDENCY_HOLD_VARIABLE]);
}
function validateAgentViewArtifact(artifact) {
if (!artifact.bundle.includes('agent_view') || !artifact.bundle.includes('agent_description')) {
throw new Error(
'Generated Cloudflare artifact is missing the permanent Agent View contract.',
);
}
}
function validateDeploymentArtifact(artifact, options = {}) {
validateArtifactIdentity(artifact, options);
validateDeploymentProfile(artifact);
validateAgentViewArtifact(artifact);
validateFlue2CutoverArtifact(artifact);
validateRoutineArtifact(artifact);
validateLedgerCanaryArtifact(artifact);
validateTurnExecutorArtifact(artifact);
validateResidencyHold(artifact);
return artifact;
}
let builtArtifact;
let deploymentTargetTuple;
try {
const artifact = requireBuiltArtifact();
if (upgradeContextPath) {
const target = validateTarget(upgradeContext.target);
if (upgradeContext.schema !== 1 || !target.url || !explicitProductionTarget || selectedEnvironmentTarget ||
target.account !== process.env.CLOUDFLARE_ACCOUNT_ID || target.worker !== process.env.WRANGLER_CI_OVERRIDE_NAME ||
target.profile !== resolveCloudflareDeploymentProfile() || JSON.stringify(deployArgs) !== JSON.stringify(wranglerProfileArgs(target)) ||
artifact.config.vars?.CHICKPEA_APP_VERSION !== upgradeContext.source?.version ||
artifact.config.vars?.CHICKPEA_SOURCE_COMMIT !== upgradeContext.source?.commit) {
throw new Error('Upgrade context does not match the selected target, source, or guarded command.');
}
artifact.config = overlayInstallation(artifact.config, upgradeContext.installation, target);
writeFileSync(artifact.configPath, `${JSON.stringify(artifact.config, null, 2)}\n`);
}
builtArtifact = validateDeploymentArtifact(artifact);
deploymentTargetTuple = readCloudflareDeploymentTargetTuple(builtArtifact.config);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
if (deploymentTargetTuple) {
process.stdout.write(`${formatCloudflareDeploymentTargetTuple(deploymentTargetTuple)}\n`);
}
if (selectedEnvironmentTarget && deploymentTargetTuple?.target !== selectedEnvironmentTarget) {
console.error(
`Selected environment ${selectedEnvironmentTarget} does not match the generated target tuple.`,
);
process.exit(1);
}
if (selectedEnvironmentTarget && deploymentTargetTuple?.stateMode !== 'permanent') {
console.error(
`Claimed environment ${selectedEnvironmentTarget} requires its registered immutable AUTH_DB and schema generation; disposable target mutation is refused.`,
);
process.exit(1);
}
if (preflightOnly) {
process.stdout.write('Permanent Cloudflare capability preflight passed. No deployment was attempted.\n');
process.exit(0);
}
try {
if (checkAccount) {
if (checkedAccount) assertCloudflareAccountConfig(accountCheckOptions(builtArtifact.configPath), checkedAccount);
else checkedAccount = await checkDeploymentAccount(builtArtifact.configPath);
}
} catch (error) {
console.error(error.message);
process.exit(1);
}
let prebuiltSandboxImage;
if (guardedSandboxDeploy) {
try {
prebuiltSandboxImage = await prebuildSandboxImage({
...sandboxToolOptions(builtArtifact.configPath),
applicationName: sandboxApplicationName(builtArtifact.config),
});
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
const AUTH_SECRET = 'CHICKPEA_AUTH_SECRET';
const CREDENTIAL_CURRENT_KEY = 'CHICKPEA_CREDENTIAL_KEY_CURRENT_ID';
const CREDENTIAL_KEY_PREFIX = 'CHICKPEA_CREDENTIAL_KEY_';
const INITIAL_CREDENTIAL_KEY_ID = 'key_v1';
const INITIAL_CREDENTIAL_KEY_SLOT = `${CREDENTIAL_KEY_PREFIX}KEY_V1`;
const DEFAULT_ACTIVE_WORKER_INSPECTION_TIMEOUT_MS = 30_000;
const configuredInspectionTimeout = Number(
process.env.CHICKPEA_DEPLOY_INSPECTION_TIMEOUT_MS,
);
const activeWorkerInspectionTimeoutMs = Number.isFinite(configuredInspectionTimeout) &&
configuredInspectionTimeout > 0
? Math.min(configuredInspectionTimeout, DEFAULT_ACTIVE_WORKER_INSPECTION_TIMEOUT_MS)
: DEFAULT_ACTIVE_WORKER_INSPECTION_TIMEOUT_MS;
function inspector(artifact) {
return wranglerInspector({ root: runnerRoot, configPath: artifact.configPath,
args: deploymentResourceArgs(), timeout: activeWorkerInspectionTimeoutMs });
}
function inspectRemoteWorker(artifact) { return inspector(artifact).worker(); }
function activeDeployment(artifact) {
try { return inspector(artifact).active(); } catch {
throw new Error('Unable to inspect the active Worker deployment. Refusing an update without its current AUTH_DB binding.');
}
}
function assertActiveDeploymentUnchanged(artifact, expectedFingerprint) {
const currentFingerprint = deploymentFingerprint(activeDeployment(artifact));
if (currentFingerprint === expectedFingerprint) return;
throw new Error(
'The active Worker deployment changed while this deploy was preparing. ' +
'Another task is using the same deployment target; refusing to replace its canary. ' +
'Coordinate the live acceptance run, then rebuild and deploy again.',
);
}
function deployedAuthDatabase(artifact) {
const versions = activeDeployment(artifact);
const databaseIds = [];
const setupAuthorities = [];
let versionsWithoutAuthDatabase = 0;
let sandboxBound = false;
for (const version of versions) {
let details;
try { details = inspector(artifact).version(version.version_id); } catch {
throw new Error(`Unable to inspect active Worker version ${version.version_id}. Refusing an update without its current AUTH_DB binding.`);
}
const allBindings = details?.resources?.bindings ?? [];
if (allBindings.some((binding) => binding?.type === 'durable_object_namespace'
&& (binding.name === 'SANDBOX' || binding.class_name === 'Sandbox'))) {
sandboxBound = true;
}
const bindings = allBindings.filter(
(binding) => binding?.name === 'AUTH_DB' && binding?.type === 'd1',
);
const setupDigestBindings = allBindings.filter((binding) =>
binding?.name === SETUP_CAPABILITY_DIGEST_BINDING
&& typeof (binding.text ?? binding.value) === 'string'
);
const setupIssuedBindings = allBindings.filter((binding) =>
binding?.name === SETUP_CAPABILITY_ISSUED_AT_BINDING
&& typeof (binding.text ?? binding.value) === 'string'
);
if (selectedEnvironmentTarget || upgradeContext) {
if (setupDigestBindings.length !== 1 || setupIssuedBindings.length !== 1) {
throw new Error('Claimed environment active versions must expose one preserved setup authority.');
}
const digest = setupDigestBindings[0].text ?? setupDigestBindings[0].value;
const issuedAt = setupIssuedBindings[0].text ?? setupIssuedBindings[0].value;
if (!/^[A-Za-z0-9_-]{43}$/u.test(digest) || !/^\d{13}$/u.test(issuedAt)) {
throw new Error('Claimed environment setup authority is unreadable; refusing rotation.');
}
setupAuthorities.push(`${digest}:${issuedAt}`);
}
if (bindings.length > 1) {
throw new Error(`Active Worker version ${version.version_id} has duplicate AUTH_DB bindings.`);
}
if (bindings.length === 0) {
versionsWithoutAuthDatabase += 1;
continue;
}
const databaseId = bindings[0].database_id ?? bindings[0].id;
if (typeof databaseId !== 'string' || !databaseId.trim()) {
throw new Error(`Active Worker version ${version.version_id} has an unreadable AUTH_DB binding.`);
}
databaseIds.push(databaseId.trim());
}
if (versionsWithoutAuthDatabase > 0 && databaseIds.length > 0) {
throw new Error('Active Worker versions disagree about whether AUTH_DB is bound; refusing to guess.');
}
const uniqueIds = [...new Set(databaseIds)];
if (uniqueIds.length > 1) {
throw new Error('Active Worker versions use different AUTH_DB databases; refusing to update either one.');
}
const uniqueSetupAuthorities = [...new Set(setupAuthorities)];
if ((selectedEnvironmentTarget || upgradeContext) && uniqueSetupAuthorities.length !== 1) {
throw new Error('Active Worker versions disagree about setup authority; refusing rotation.');
}
const [setupDigest, setupIssuedAt] = uniqueSetupAuthorities[0]?.split(':') ?? [];
return {
databaseId: uniqueIds[0],
fingerprint: deploymentFingerprint(versions),
sandboxBound,
servingVersionId: versions.length === 1 ? versions[0].version_id : undefined,
...((selectedEnvironmentTarget || upgradeContext) ? {
setupAuthority: { digest: setupDigest, issuedAt: Number(setupIssuedAt) },
} : {}),
};
}
async function prepareDeploymentAuthority(artifact, secretNames, preservedSetupAuthority) {
const generatedSecrets = {};
if (!secretNames.has(AUTH_SECRET)) {
if (selectedEnvironmentTarget || upgradeContext) {
throw new Error('Existing installation is missing its permanent auth authority; refusing rotation.');
}
generatedSecrets[AUTH_SECRET] = (await mintSetupCapability()).capability;
}
const hasCredentialCurrent = secretNames.has(CREDENTIAL_CURRENT_KEY);
const credentialSlots = [...secretNames].filter((name) =>
name.startsWith(CREDENTIAL_KEY_PREFIX) && name !== CREDENTIAL_CURRENT_KEY
);
if (!hasCredentialCurrent && credentialSlots.length === 0) {
if (selectedEnvironmentTarget || upgradeContext) {
throw new Error(
'Existing installation credential encryption authority is missing; refusing to rotate encrypted connections.',
);
}
generatedSecrets[CREDENTIAL_CURRENT_KEY] = INITIAL_CREDENTIAL_KEY_ID;
generatedSecrets[INITIAL_CREDENTIAL_KEY_SLOT] = randomBytes(32).toString('base64url');
} else if (!hasCredentialCurrent || credentialSlots.length === 0) {
throw new Error(
'Cloudflare Slack credential encryption is only partially provisioned. ' +
'Restore CHICKPEA_CREDENTIAL_KEY_CURRENT_ID and its versioned ' +
'CHICKPEA_CREDENTIAL_KEY_<ID> slot before deploying.',
);
}
const setup = preservedSetupAuthority ?? await mintSetupCapability();
artifact.config.vars = {
...(artifact.config.vars ?? {}),
[SETUP_CAPABILITY_DIGEST_BINDING]: setup.digest,
[SETUP_CAPABILITY_ISSUED_AT_BINDING]: String(setup.issuedAt),
};
writeFileSync(artifact.configPath, `${JSON.stringify(artifact.config, null, 2)}\n`);
return { generatedSecrets, setup };
}
async function bindDeploymentActivation(artifact) {
const activation = await mintDeploymentActivation();
artifact.config.vars = {
...(artifact.config.vars ?? {}),
[DEPLOYMENT_ACTIVATION_DIGEST_BINDING]: activation.digest,
[DEPLOYMENT_ACTIVATION_ISSUED_AT_BINDING]: String(activation.issuedAt),
};
writeFileSync(artifact.configPath, `${JSON.stringify(artifact.config, null, 2)}\n`);
return activation;
}
function createSecretsFile(secrets) {
if (Object.keys(secrets).length === 0) return undefined;
const directory = mkdtempSync(path.join(tmpdir(), 'chickpea-deploy-secrets-'));
const file = path.join(directory, 'secrets.json');
try {
writeFileSync(file, `${JSON.stringify(secrets)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
} catch (error) {
rmSync(directory, { recursive: true, force: true });
throw error;
}
return { directory, file };
}
function removeSecretsFile(prepared) {
if (prepared) rmSync(prepared.directory, { recursive: true, force: true });
}
function deploymentResourceArgs() {
const resolved = new Map();
for (let index = 0; index < deployArgs.length; index += 1) {
const argument = deployArgs[index];
if (['--env', '-e', '--profile'].includes(argument)) {
const flag = argument === '-e' ? '--env' : argument;
const value = deployArgs[index + 1];
if (!value || value.startsWith('--') || resolved.has(flag)) {
throw new Error(`Claimed deployment has ambiguous ${flag} provider context.`);
}
resolved.set(flag, value);
index += 1;
} else if (argument.startsWith('--env=') || argument.startsWith('--profile=')) {
const separator = argument.indexOf('=');
const flag = argument.slice(0, separator);
const value = argument.slice(separator + 1);
if (!value || resolved.has(flag)) {
throw new Error(`Claimed deployment has ambiguous ${flag} provider context.`);
}
resolved.set(flag, value);
}
}
return ['--profile', '--env'].flatMap((flag) =>
resolved.has(flag) ? [flag, resolved.get(flag)] : []
);
}
function ensureAuthDatabase(artifact, deployedDatabaseId) {
const authDb = (artifact.config.d1_databases ?? []).find(
(binding) => binding.binding === 'AUTH_DB',
);
const targetTuple = readCloudflareDeploymentTargetTuple(artifact.config);
if (deployedDatabaseId) {
const generatedId = typeof authDb?.database_id === 'string'
? authDb.database_id.trim()
: '';
if (generatedId && generatedId !== deployedDatabaseId) {
throw new Error(
`The generated AUTH_DB database (${generatedId}) differs from the deployed database ` +
`(${deployedDatabaseId}); refusing to replace customer auth data.`,
);
}
process.stdout.write('Preserving the deployed AUTH_DB database...\n');
authDb.database_id = deployedDatabaseId;
writeFileSync(artifact.configPath, `${JSON.stringify(artifact.config, null, 2)}\n`);
return validateDeploymentArtifact(requireBuiltArtifact(), { requireDatabaseId: true });
}
if (typeof authDb?.database_id === 'string' && authDb.database_id.trim()) return artifact;
const existingId = existingAuthDatabaseId(authDb?.database_name || 'chickpea-auth-db');
if (existingId) {
if (targetTuple?.stateMode === 'disposable') {
throw new Error(
`Cloudflare disposable target ${targetTuple.target} already has an existing AUTH_DB. ` +
'Remove that disposable state or register its immutable ID and schema generation before deploying.',
);
}
process.stdout.write('Reusing the customer-owned AUTH_DB database...\n');
authDb.database_id = existingId;
writeFileSync(artifact.configPath, `${JSON.stringify(artifact.config, null, 2)}\n`);
return validateDeploymentArtifact(requireBuiltArtifact(), { requireDatabaseId: true });
}
const rootConfig = path.join(projectRoot, 'wrangler.jsonc');
const provisionConfig = targetTuple ? artifact.configPath : rootConfig;
process.stdout.write('Provisioning the customer-owned AUTH_DB database...\n');
const provision = spawnSync(
process.execPath,
[
wranglerBin,
'd1',
'create',
authDb?.database_name || 'chickpea-auth-db',
'--binding',
'AUTH_DB',
'--update-config',
'--config',
provisionConfig,
...deploymentResourceArgs(),
],
{ cwd: projectRoot, stdio: 'inherit' },
);
if (provision.error) {
console.error(`Unable to start AUTH_DB provisioning: ${provision.error.message}`);
process.exit(1);
}
if (provision.status !== 0) {
console.error(
'AUTH_DB provisioning failed. If the database already exists, copy its ID into ' +
'wrangler.jsonc and rerun npm run deploy.',
);
process.exit(provision.status ?? 1);
}
if (targetTuple) {
return validateDeploymentArtifact(requireBuiltArtifact(), { requireDatabaseId: true });
}
buildCloudflareArtifact();
const rebuilt = requireBuiltArtifact();
return validateDeploymentArtifact(rebuilt, { requireDatabaseId: true });
}
function existingAuthDatabaseId(databaseName) {
const list = spawnSync(
process.execPath,
[wranglerBin, 'd1', 'list', '--json', ...deploymentResourceArgs()],
{ cwd: projectRoot, encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] },
);
if (list.error) {
console.error(`Unable to inspect AUTH_DB resources: ${list.error.message}`);
process.exit(1);
}
if (list.status !== 0) process.exit(list.status ?? 1);
let databases;
try {
databases = JSON.parse(list.stdout);
} catch {
console.error('AUTH_DB discovery returned an unreadable response.');
process.exit(1);
}
if (!Array.isArray(databases)) {
console.error('AUTH_DB discovery returned an unexpected response.');
process.exit(1);
}
const matches = databases.filter((database) => database?.name === databaseName);
if (matches.length > 1) {
console.error(`Multiple D1 databases are named ${databaseName}; refusing to guess.`);
process.exit(1);
}
const id = matches[0]?.uuid ?? matches[0]?.id;
return typeof id === 'string' && id.trim() ? id.trim() : undefined;
}
function verifyRemoteAuthSchema(artifact) {
const expected = expectedAuthSchema(artifact);
const inspection = spawnSync(
process.execPath,
[
wranglerBin,
'd1',
'execute',
'AUTH_DB',
'--remote',
'--json',
'--command',
AUTH_SCHEMA_QUERY,
'--config',
artifact.configPath,
...deploymentResourceArgs(),
],
{ cwd: projectRoot, encoding: 'utf8' },
);
if (inspection.error || inspection.status !== 0) {
throw new Error(
'Unable to inspect the migrated AUTH_DB schema. Refusing to upload the Worker.',
);
}
let payload;
try {
payload = JSON.parse(inspection.stdout);
} catch {
throw new Error('AUTH_DB schema inspection returned unreadable JSON.');
}
const statements = Array.isArray(payload) ? payload : [];
const resultSets = statements.filter((statement) => Array.isArray(statement?.results));
if (resultSets.length !== 1 || resultSets[0]?.success === false) {
throw new Error('AUTH_DB schema inspection returned an unexpected result.');
}
const actual = normalizeAuthSchemaRows(resultSets[0].results);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
'AUTH_DB contains an incompatible reviewed Better Auth migration-chain schema. ' +
'Its schema structure differs from the reviewed migration chain. Preserve the database and investigate the mismatch before deploying.',
);
}
process.stdout.write('Verified the reviewed Better Auth schema in AUTH_DB...\n');
}
let deploymentAuthority;
let remoteWorker;
let expectedActiveDeploymentFingerprint;
let previousServingVersionId;
let finalEnvironmentPreflight;
if (!deployArgs.includes('--dry-run')) {
try {
if (qaSourceAdmission) qaCandidateApi.recheckQaCandidate(qaSourceAdmission);
if (upgradeContext) {
const current = validateInstallation(inspector(builtArtifact).inspect());
assertSameInstallation(upgradeContext.installation, current);
verifyRemoteAuthSchema(builtArtifact);
}
remoteWorker = inspectRemoteWorker(builtArtifact);
if (installationLane && remoteWorker.exists) {
throw new Error('The reserved fresh-install Worker already exists. Preserve it for recovery; a fresh install must not overwrite existing Worker state.');
}
if (selectedEnvironmentTarget && !remoteWorker.exists) {
throw new Error(
`Claimed environment ${selectedEnvironmentTarget} has no existing Worker; refusing to provision permanent lane infrastructure.`,
);
}
if (deploymentTargetTuple?.stateMode === 'disposable' && remoteWorker.exists) {
throw new Error(
`Cloudflare disposable target ${deploymentTargetTuple.target} already has an existing Worker ` +
'and Durable Object state. Remove that disposable state or register its immutable AUTH_DB ID ' +
'and schema generation before deploying.',
);
}
const deployed = remoteWorker.exists
? deployedAuthDatabase(builtArtifact)
: undefined;
if (upgradeContext && (!remoteWorker.exists || !deployed?.databaseId)) {
throw new Error('Upgrade requires the existing Worker and AUTH_DB; provisioning is forbidden.');
}
expectedActiveDeploymentFingerprint = deployed?.fingerprint;
previousServingVersionId = deployed?.servingVersionId;
if (deployed?.sandboxBound && deploymentProfile === 'core' && !explicitCoreProfile) {
throw new Error(
'The live Worker has the coding sandbox (SANDBOX binding), and this command would deploy the core profile ' +
'and remove it. To keep the sandbox, run the same command as `npm run deploy:sandbox` with the same flags and ' +
'environment (for Workers Builds, keep the CHICKPEA_DEPLOY_PROFILE=sandbox build variable). To uninstall it ' +
'on purpose, set CHICKPEA_DEPLOY_PROFILE=core and rerun. Nothing was changed.',
);
}
builtArtifact = ensureAuthDatabase(builtArtifact, deployed?.databaseId);
// This is the final gate immediately before the first mutation of an
// existing customer resource. It intentionally reruns after both D1 reuse
// injection and D1 provisioning/rebuild.
builtArtifact = validateDeploymentArtifact(builtArtifact, { requireDatabaseId: true });
deploymentAuthority = await prepareDeploymentAuthority(
builtArtifact,
remoteWorker.names,
deployed?.setupAuthority,
);
if (selectedEnvironmentTarget) {
const environmentOptions = {
projectRoot,
config: builtArtifact.config,
configPath: builtArtifact.configPath,
targetTuple: deploymentTargetTuple,
providerContext: deploymentResourceArgs(),
};
if (resumedEnvironmentDeployment) {
const finalResume = await environmentPreflightApi.recheckResumedEnvironmentDeployment(
resumedEnvironmentDeployment,
environmentOptions,
);
finalEnvironmentPreflight = finalResume.preflight;
environmentMutationLease = finalResume.mutationLease;
} else {
finalEnvironmentPreflight = await environmentPreflightApi.preflightEnvironmentMutation(
selectedEnvironmentTarget,
environmentOptions,
);
}
environmentPreflightApi.assertSameEnvironmentMutationAuthority(
initialEnvironmentPreflight,
finalEnvironmentPreflight,
);
builtArtifact.config.vars = {
...(builtArtifact.config.vars ?? {}),
// The registry owns this non-secret, project-scoped baseline setting.
// Keep the reviewed standard grant across branch deployments. The
// smoke contract exercises reads; it does not narrow OAuth consent.
COMPOSIO_SHEETS_READ_AUTH_CONFIG_ID:
finalEnvironmentPreflight.registration.providerAuthConfigId,
COMPOSIO_SHEETS_WRITE_AUTH_CONFIG_ID:
finalEnvironmentPreflight.registration.providerAuthConfigId,
...environmentPreflightApi.environmentDeploymentMetadataBindings(
finalEnvironmentPreflight.deploymentMetadata,
),
};
writeFileSync(
builtArtifact.configPath,
`${JSON.stringify(builtArtifact.config, null, 2)}\n`,
);
builtArtifact = validateDeploymentArtifact(requireBuiltArtifact(), {
requireDatabaseId: true,
});
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
if (selectedEnvironmentTarget) {
try {
if (qaSourceAdmission) qaCandidateApi.recheckQaCandidate(qaSourceAdmission);
environmentMutationLease ??= environmentPreflightApi.beginEnvironmentDeployment(
finalEnvironmentPreflight,
{ projectRoot, providerContext: deploymentResourceArgs() },
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// D1 migrations are forward-only and idempotent. Apply them before the Worker
// starts serving a schema it expects. If deploy later fails, rerunning this
// command resumes from D1's migration ledger; never attempt schema rollback.
if (!deployArgs.includes('--dry-run') && !upgradeContext) {
process.stdout.write('Applying reviewed Better Auth migrations to AUTH_DB...\n');
const environmentArgs = deploymentResourceArgs();
// Authority preparation above can await provider reads. Fence the actual
// migration inputs again at the irreversible D1 boundary, including resumes.
try { if (qaSourceAdmission) qaCandidateApi.recheckQaCandidate(qaSourceAdmission); }
catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const migration = spawnSync(
process.execPath,
[
wranglerBin,
'd1',
'migrations',
'apply',
'AUTH_DB',
'--remote',
'--config',
builtArtifact.configPath,
...environmentArgs,
],
{ cwd: projectRoot, stdio: 'inherit' },
);
if (migration.error) {
console.error(`Unable to start AUTH_DB migration: ${migration.error.message}`);
process.exit(1);
}
if (migration.status !== 0) process.exit(migration.status ?? 1);
try {
verifyRemoteAuthSchema(builtArtifact);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
let preparedSecrets;
try {
if (deploymentAuthority) {
deploymentAuthority.activation = await bindDeploymentActivation(builtArtifact);
}
// Operator-supplied secrets (for example a QA lane's provider key) ride the
// same atomic secrets file, so one deploy yields one live version and the
// receipt keeps matching the Worker. Names are logged; values never are.
const operatorSecrets = process.env[OPERATOR_SECRETS_ENV]
? readOperatorSecretsFile(process.env[OPERATOR_SECRETS_ENV])
: undefined;
if (operatorSecrets) {
console.log(`Deploying with operator secrets: ${Object.keys(operatorSecrets).join(', ')}`);
}
// A claimed lane also carries the standing provider keys from the
// operator's lane secrets file, so a rebuilt lane regains them. An explicit
// operator secrets file wins over it, and the wrapper's own names win over both.
const laneSecrets = resolveLaneSecrets(requestedDeploymentTarget);
if (laneSecrets) console.log(describeLaneSecrets(laneSecrets));
// Each lane also keeps one seed token for `npm run lane:seed`.
const laneSeedToken = ensureLaneSeedToken(requestedDeploymentTarget);
if (laneSeedToken) console.log(`Lane seed token: installed (${LANE_SEED_TOKEN_BINDING}).`);
preparedSecrets = createSecretsFile(mergeDeploymentSecrets(
{
...(deploymentAuthority?.generatedSecrets ?? {}),
...(laneSeedToken ? { [LANE_SEED_TOKEN_BINDING]: laneSeedToken } : {}),
},
{ ...(laneSecrets?.secrets ?? {}), ...(operatorSecrets ?? {}) },
));
} catch (error) {
console.error(`Unable to prepare the temporary Worker secrets file: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
// The inspection above intentionally happens after the build, when this
// deploy knows the exact target and preserved bindings. A second task may
// still finish its own deploy while migrations/readiness preparation runs.
// Re-read the serving versions at the last safe boundary so overlapping
// disposable acceptance runs fail closed instead of silently replacing one
// another's Worker and interrupting their admitted Slack turns.
if (expectedActiveDeploymentFingerprint) {
try {
assertActiveDeploymentUnchanged(builtArtifact, expectedActiveDeploymentFingerprint);
} catch (error) {
removeSecretsFile(preparedSecrets);
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
if (selectedEnvironmentTarget) {
try {
environmentPreflightApi.recheckEnvironmentMutationAuthority(
finalEnvironmentPreflight,
{
projectRoot,
config: builtArtifact.config,
configPath: builtArtifact.configPath,
targetTuple: deploymentTargetTuple,
providerContext: deploymentResourceArgs(),
mutationLease: environmentMutationLease,
},
);
} catch (error) {
removeSecretsFile(preparedSecrets);
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
if (upgradeContext) {
try {
assertSameInstallation(upgradeContext.installation, validateInstallation(inspector(builtArtifact).inspect()));
verifyRemoteAuthSchema(builtArtifact);
writeDeploymentEvent(upgradeContextPath, 'deploying');
} catch (error) {
removeSecretsFile(preparedSecrets);
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
try {
if (qaSourceAdmission) qaCandidateApi.recheckQaCandidate(qaSourceAdmission);
if (prebuiltSandboxImage || sandboxCheckpointsOff) {
// Last write before the upload: Wrangler now applies the Container
// application from the already-pushed image instead of building it after
// the new Worker version is live.
if (prebuiltSandboxImage) useSandboxImage(builtArtifact.config, prebuiltSandboxImage);
if (sandboxCheckpointsOff) withoutCheckpointBucket(builtArtifact.config);
writeFileSync(builtArtifact.configPath, `${JSON.stringify(builtArtifact.config, null, 2)}\n`);
}
} catch (error) {
removeSecretsFile(preparedSecrets);
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
const child = spawn(
process.execPath,
[
wranglerBin,
...(upgradeContext ? ['versions', 'upload'] : ['deploy']),
...deployArgs,
...(preparedSecrets ? ['--secrets-file', preparedSecrets.file] : []),
],
{ cwd: projectRoot, stdio: ['inherit', 'pipe', 'inherit'] },
);
let activeChild = child;
let cleanedSecrets = false;
function cleanupSecrets() {
if (cleanedSecrets) return;
cleanedSecrets = true;
removeSecretsFile(preparedSecrets);
}
process.once('exit', cleanupSecrets);
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.once(signal, () => {
cleanupSecrets();
activeChild.kill(signal);
process.exit(signal === 'SIGINT' ? 130 : 143);
});
}
let deployedUrl = '';
let deployedVersionId = '';
let workerUploaded = false;
let tail = '';
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
// Line-oriented scan without unbounded buffering: keep only a joining tail
// in case the URL straddles a chunk boundary.
const text = tail + chunk.toString('utf8');
const match = text.match(/https?:\/\/[^\s]+\.workers\.dev\b/);
if (match && !deployedUrl) {
deployedUrl = match[0];
}
if (!workerUploaded && uploadedBeforeFailure(text)) workerUploaded = true;
const versionMatch = text.match(/(?:Current|Worker) Version ID:\s*([A-Za-z0-9-]+)/);
if (versionMatch?.[1] && versionMatch[1] !== deployedVersionId) {
deployedVersionId = versionMatch[1];
if (upgradeContext) writeDeploymentEvent(upgradeContextPath, 'uploaded', deployedVersionId);
}
tail = text.slice(-256);
});
const DEFAULT_DEPLOYMENT_READINESS_TIMEOUT_MS = 6 * 60 * 1_000;
const MAX_DEPLOYMENT_READINESS_TIMEOUT_MS = 10 * 60 * 1_000;
const configuredReadinessTimeout = Number(process.env.CHICKPEA_DEPLOY_READINESS_TIMEOUT_MS);
const deploymentReadinessTimeoutMs = Number.isFinite(configuredReadinessTimeout) &&
configuredReadinessTimeout > 0
? Math.min(configuredReadinessTimeout, MAX_DEPLOYMENT_READINESS_TIMEOUT_MS)
: DEFAULT_DEPLOYMENT_READINESS_TIMEOUT_MS;
// Test hook: comma-separated `status` or `status:error` readiness responses.
const testReadinessStatuses = process.env.DEPLOY_TEST_READINESS_STATUSES
?.split(',')
.map((value) => {
const [status, error] = value.trim().split(':');
return { status: Number(status), error: error || undefined };
})
.filter(({ status }) => Number.isInteger(status));
let testReadinessIndex = 0;
const READINESS_WAIT_REASONS = {
worker_version_pending: 'the new Worker version is not serving every request yet',
gateway_session_stale_version: "the Slack gateway session still runs the previous Worker version",
gateway_session_offline: "this version's Slack gateway session is not connected yet",
gateway_session_unconfirmed:
"the Slack gateway did not answer on this version's session; reconnecting it",
gateway_upgrade_pending: 'Slack gateway HTTP delivery is still being registered',
};
function readinessWaitReason(result) {
if (result.status === 0) return 'the Worker did not answer the readiness check';
// The previous version does not know this deploy's activation capability.
if (result.status === 404) return READINESS_WAIT_REASONS.worker_version_pending;
return READINESS_WAIT_REASONS[result.error] ??
`the readiness check returned HTTP ${result.status}${result.error ? ` (${result.error})` : ''}`;
}
function readinessBaseUrl() {
if (upgradeContext) return upgradeContext.target.url;
if (deployedUrl) return deployedUrl;
if (process.env.DEPLOY_TEST_READINESS_BASE_URL) {
return process.env.DEPLOY_TEST_READINESS_BASE_URL;
}
const configured = builtArtifact.config.vars?.SLACK_TAG_PUBLIC_URL;
return typeof configured === 'string' && configured.trim()
? configured.trim().replace(/\/+$/, '')
: undefined;
}
async function requestDeploymentReadiness(baseUrl, versionId, activation) {
if (testReadinessStatuses?.length) {
const result = testReadinessStatuses[
Math.min(testReadinessIndex, testReadinessStatuses.length - 1)
];
testReadinessIndex += 1;
return result;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch(new URL('/internal/deployment/ready', baseUrl), {
method: 'POST',
headers: {
Authorization: `Bearer ${activation.capability}`,
'X-Chickpea-Target-Version': versionId,
...(upgradeContext?.recovery?.digest ? { 'X-Chickpea-Recovery-Digest': upgradeContext.recovery.digest } : {}),
},
redirect: 'manual',
signal: controller.signal,
});
let error;
if (response.status !== 204) {
try {
const body = await response.json();
if (typeof body?.error === 'string' && /^[a-z0-9_]{1,64}$/.test(body.error)) error = body.error;
} catch {
// A body that is not the readiness JSON only loses the reason.
}
}
return { status: response.status, error };
} catch {
return { status: 0 };
} finally {
clearTimeout(timeout);
}
}
// Readiness requires the new version to serve, its Slack gateway session to run
// that version, and the gateway to answer on that session. Local session health
// alone can trust a socket the gateway has dropped, which delays the next Slack
// event until Slack retries it about a minute later.
async function waitForDeploymentReadiness(baseUrl, versionId, activation) {
const deadline = Date.now() + deploymentReadinessTimeoutMs;
process.stdout.write('Waiting for the current Worker and Slack gateway version...\n');
let lastReason;
while (Date.now() < deadline) {
const result = await requestDeploymentReadiness(baseUrl, versionId, activation);
if (result.status === 204) {
process.stdout.write('Verified current-version deployment readiness.\n');
return;
}
const reason = readinessWaitReason(result);
if (reason !== lastReason) process.stdout.write(` Still waiting: ${reason}.\n`);
lastReason = reason;
if (testReadinessStatuses?.length &&
testReadinessIndex >= testReadinessStatuses.length) break;
if (!testReadinessStatuses?.length) {
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
throw new Error(
'Worker uploaded, but current-version readiness was not confirmed within ' +
`${Math.round(deploymentReadinessTimeoutMs / 1_000)} s. Last status: ${lastReason ?? 'no answer'}. ` +
'Slack events can reach this Worker late or not at all until its gateway session connects; ' +
'rerun the deploy or check Admin → Settings → Slack.',
);
}
const RULE = '────────────────────────────────────────────────────────';
function printPrivateSetupLink(baseUrl, setup) {
const privateSetupUrl = setupCapabilityUrl(baseUrl, setup.capability);
process.stdout.write(
[
'',
RULE,
' ✔ Worker deployed.',
'',
' 🔐 PRIVATE SETUP LINK — COPY AND OPEN THIS',
RULE,
`👉 ${privateSetupUrl}`,
].join('\n'),
);
}
function printPrivateSetupPath(setup) {
const privatePath = new URL(setupCapabilityUrl('https://chickpea.invalid', setup.capability));
process.stdout.write(
[
'',
RULE,
' ✔ Worker deployed.',
'',
' 🔐 PRIVATE SETUP PATH — copy and open this on your configured Chickpea domain',
RULE,
`👉 ${privatePath.pathname}${privatePath.hash}`,
].join('\n'),
);
}
// Workers Builds receives stdout through a pipe. Let Node exit naturally after
// this callback so the final setup link and Cloudflare's completion event can
// flush; process.exit() can truncate asynchronous pipe writes.
function sandboxRerun() {
return rerunCommand({ explicitProductionTarget, deployArgs, workersBuilds: process.env.WORKERS_CI === '1' });
}
function sandboxRecovery() {
return sandboxPartialDeployRecovery({
workerName: builtArtifact.config.name,
previousVersionId: previousServingVersionId,
providerContext: deploymentResourceArgs(),
rerun: sandboxRerun(),
});
}
child.on('close', async (code) => {
cleanupSecrets();
if (code !== 0) {
// Wrangler activates the uploaded version before it creates the Container
// application, so a failure after `Uploaded <worker>` is a live partial
// deploy. Earlier failures (resource provisioning such as the checkpoint
// R2 bucket runs after the asset upload) leave the live version unchanged.
if (deploymentProfile === 'sandbox') {
console.error(workerUploaded || deployedVersionId
? sandboxRecovery()
: sandboxStoppedBeforeUpload({ rerun: sandboxRerun() }));
}
process.exitCode = code ?? 1;
return;
}
// A dry run deploys nothing — next-steps instructions would be a lie.
if (deployArgs.includes('--dry-run')) {
return;
}
const baseUrl = readinessBaseUrl();
if (!baseUrl || !deployedVersionId || !deploymentAuthority) {
console.error(
'Worker uploaded, but its version or public origin was unavailable for readiness verification.',
);
process.exitCode = 1;
return;
}
try {
if (upgradeContext) {
// Upload does not change traffic. Recheck the serving installation before
// activation, and use an isolated minimal config so Wrangler cannot sync
// authored routes, crons, observability or other non-versioned settings.
assertSameInstallation(upgradeContext.installation, validateInstallation(inspector(builtArtifact).inspect()));
const activationConfig = path.join(path.dirname(upgradeContextPath), 'activation-wrangler.json');
writePrivateJson(activationConfig, { name: upgradeContext.target.worker, account_id: upgradeContext.target.account, compatibility_date: builtArtifact.config.compatibility_date });
await new Promise((resolve, reject) => {
activeChild = spawn(process.execPath, [wranglerBin, 'versions', 'deploy', `${deployedVersionId}@100%`, '--yes', '--config', activationConfig, ...deploymentResourceArgs()],
{ cwd: path.dirname(activationConfig), stdio: 'inherit' });
activeChild.once('error', () => reject(new Error('Unable to activate the uploaded Worker version. Preserve the receipt.')));
activeChild.once('close', (status) => status === 0 ? resolve() : reject(new Error('Worker activation was not verified. Preserve the receipt and resume.')));
});
}
await waitForDeploymentReadiness(
baseUrl,
deployedVersionId,
deploymentAuthority.activation,
);
if (guardedSandboxDeploy) {
const container = verifySandboxContainerApplication({
...sandboxToolOptions(builtArtifact.configPath),
applicationName: sandboxApplicationName(builtArtifact.config),
});
if (container.missing) throw new Error(`${container.message}\n${sandboxRecovery()}`);
// An unreadable listing is not evidence of failure; say what is unknown.
if (!container.ok) process.stdout.write(`! ${container.message}\n`);
else {
process.stdout.write(
`${container.message} The first image rollout can take several minutes (wait for state "ready"). ` +
'Then open Admin → Settings → Coding sandbox, choose Check again, and choose Enable coding sandbox: ' +
'repository grants do not use the Container until it is enabled.\n',
);
}
if (sandboxCheckpointsOff) {
process.stdout.write(`! ${checkpointsOffNotice(checkedAccount?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID)}\n`);
}
}
if (selectedEnvironmentTarget) {
await environmentPreflightApi.completeEnvironmentDeployment(
finalEnvironmentPreflight,
{
deployedVersion: deployedVersionId,
projectRoot,
providerContext: deploymentResourceArgs(),
mutationLease: environmentMutationLease,
},
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
return;
}
if (upgradeContext) {
writeDeploymentEvent(upgradeContextPath, 'ready', deployedVersionId);
process.stdout.write('\n✔ Upgrade deployment is ready. Existing setup authority preserved.\n');
} else if (selectedEnvironmentTarget) {
process.stdout.write('\n✔ Claimed environment deployment reconciled.\n');
// The skill requires a telemetry isolation receipt for the serving version
// before any synthetic Slack action. Produce it here, so a verifier never
// has to resolve the Worker and account by hand or act before it exists.
try {
const telemetry = await import('./lib/product-telemetry-preflight.mjs');
// One receipt per check: a re-deploy can serve the same version id again.
const receiptPath = path.join(finalEnvironmentPreflight.registration.evidenceRoot,
`telemetry-${deployedVersionId}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
let receipt;
try {
receipt = await telemetry.verifyProductTelemetry({
worker: builtArtifact.config.name,
...(checkedAccount?.accountId ? { accountId: checkedAccount.accountId } : {}),
providerContext: deploymentResourceArgs(),
projectRoot,
});
} catch (error) {
if (!(error instanceof telemetry.ProductTelemetryPreflightError)) throw error;
receipt = error.receipt;
}
telemetry.writeProductTelemetryReceipt(receiptPath, receipt, projectRoot);
if (receipt.status === 'passed') {
process.stdout.write(`✔ Telemetry isolation verified for the serving version; receipt ${receiptPath}\n`);
} else {
process.stderr.write(`✖ Telemetry isolation failed (${receipt.failure?.code ?? 'unknown'}); receipt ${receiptPath}. ` +
'Do not send synthetic Slack traffic to this lane until it passes.\n');
process.exitCode = 1;
}
} catch (error) {
process.stderr.write(`✖ Telemetry isolation was not checked: ${error instanceof Error ? error.message : String(error)}. ` +
`Run \`npm run verify:telemetry -- --target ${selectedEnvironmentTarget}\` before any synthetic Slack traffic.\n`);
process.exitCode = 1;
}
if (finalEnvironmentPreflight?.setupFlow?.proven === false) {
process.stdout.write(
'! Setup flow unproven for this revision: the first-run install sources '
+ 'differ from the lane baseline. Prove a fresh install on a disposable '
+ 'target and re-record the baseline to clear it.\n',
);
}
} else if (deployedUrl && deploymentAuthority) {
printPrivateSetupLink(deployedUrl, deploymentAuthority.setup);
} else if (deploymentAuthority) {
printPrivateSetupPath(deploymentAuthority.setup);
}
});