Repository navigation
Description
Summary
Changing a Config value that an Effect-native Worker reads in its Init body does not redeploy the Worker. alchemy deploy plans Plan: no changes / [Worker] noop, so the new value never reaches Cloudflare. The value is only uploaded when the Worker updates for another reason, such as a code change.
This affects every yield* Config.xxx(...) read in Init: feature flags, Config.Redacted secrets being rotated, and so on. Values declared statically on env (env: { X: Config.Redacted("X") }) are not affected.
Seen on [email protected]. The same code is on main at 983a8fb.
Reproduction
src/worker.ts:
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default class ReproWorker extends Cloudflare.Worker<ReproWorker>()(
"ReproWorker",
{
main: import.meta.url,
compatibility: { date: "2026-05-22", flags: ["nodejs_compat"] },
},
Effect.gen(function* () {
const mode = yield* Config.String("REPRO_MODE").pipe(Config.withDefault("unset"));
return { fetch: Effect.succeed(HttpServerResponse.text(mode)) };
}),
) {}alchemy.run.ts:
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import ReproWorker from "./src/worker.ts";
export default Alchemy.Stack(
"alchemy-config-repro",
{ providers: Cloudflare.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const worker = yield* ReproWorker;
return { url: worker.url };
}),
);Steps:
REPRO_MODE=a alchemy deploy --stage repro. The Worker is created, andcurl <url>returnsa.REPRO_MODE=b alchemy deploy --stage repro.
Expected: step 2 plans [ReproWorker] update, and curl <url> returns b.
Actual: step 2 plans Plan: no changes, and curl <url> still returns a.
We hit this in production. We changed a GitHub Actions environment variable read this way and reran the deploy job. The log showed the new value in the job environment and Plan: no changes, and the deployed Worker never got the value.
I haven't run these two deploys in a clean account. I confirmed the plan-side state offline instead, by evaluating the stack above with evalStack (dummy Cloudflare credentials; the plan phase makes no API calls). For both values, the stack has no bindings for ReproWorker, and the new value only appears in props.env:
REPRO_MODE=a bindings: [] props.env.REPRO_MODE: ApplyExpr over Redacted("a")
REPRO_MODE=b bindings: [] props.env.REPRO_MODE: ApplyExpr over Redacted("b")
The Worker diff reads the first of these and skips the second, as described below.
Cause
The value is captured correctly, but only into a part of the props that the Worker diff no longer compares:
-
Platform.makebuilds the Worker's bindings in theonCreatehook, from the static props only, before Init runs (Platform.ts#L529-L530,Worker.ts#L2576). -
Init then runs in the plan phase, and the
Configinterceptor callsruntimeContext.set(key, …)for each read (Platform.ts#L586). -
Those values are merged into
instance.Props.envonly, not into the stack's bindings (Platform.ts#L661). -
The Worker
diffthen ignoresprops.envin both of its checks:- The metadata hash covers binding data only ("Hash only that canonical wire representation",
WorkerProvider.ts#L1173). - The raw-props fallback removes
envbefore comparing (normalizeComparedProps,WorkerProvider.ts#L5129).
Both came from fix(cloudflare/workers): converge nested yielded-resource env plans #1273. Their premise is that "every
enventry is lowered into binding data bybindWorkerAsyncBindings". That holds for staticenv, but not for Init-captured config, which never goes throughbindWorkerAsyncBindings. - The metadata hash covers binding data only ("Hash only that canonical wire representation",
This looks like a regression of the case that test/Diff.test.ts "detects a changed yielded config value in env" was written for. That test still passes because it calls havePropsChanged directly. The Worker diff no longer passes env to it.
Suggested fix
Either:
- Lower Init-captured config into binding data. Then both the hash and the upload go through the same path as static
env. - Or include the Init-captured part of
props.envin the metadata hash. These values are plain strings orRedacted<string>, so they don't have the attribute-projection churn fix(cloudflare/workers): converge nested yielded-resource env plans #1273 was avoiding.
A regression test would deploy the fixture above, change the variable, and assert the plan is update. The existing WorkerEnv live test only checks the first deploy.
Workaround
alchemy deploy --force, or declare the value statically on env instead of reading it in Init.
Activity
juliusmarminge commentedon Sep 25, 2026
Heads up on the workaround above:
alchemy deploy --forcecurrently replaces unchangedPlanetscale.PostgresRoleresources (new credentials, old role deleted) when the role references aPostgresDatabase. See #1832.fix(core): plan an update when an Init-captured value changes