Alchemy v2 resources for Cloudflare Workers that
Workers Builds deploys from a Git
repository. Use them when the repository's wrangler.jsonc defines the Worker and Cloudflare
builds each commit.
The package adds only what the built-in alchemy/Cloudflare provider does not have:
WorkersBuilds.Worker: a Worker that exists without code.WorkersBuilds.Repository: the Workers Builds configuration that links a GitHub repository to that Worker.WorkersBuilds.Secret: a secret of that Worker. Wrangler keeps it on each deploy.
This is a community provider, maintained by Samebase.
Status: 0.5, pinned to alchemy@2.0.0-beta.80 and Effect 4. Alchemy ships breaking changes
between betas. Upgrade this package and Alchemy together.
Wrangler owns the version. This package owns the shell, the link, and the secrets.
- The Wrangler file in the repository is the only source of truth for the code, the bindings other than secrets, vars, assets, routes, and compatibility settings. Alchemy never uploads code and never writes these.
- Workers Builds runs
npx wrangler deployon each push to the production branch andnpx wrangler previewon each other branch. - This package creates the Worker shell, links the repository, sets the build commands, and writes the build variables.
- This package also writes the Worker secrets. Wrangler
does not delete secrets
on deploy, so a secret that this package writes stays across builds. Give each name one owner:
do not also set a secret name in
varsof the Wrangler file.
Wrangler also writes some Worker settings on each deploy: observability, logpush,
workers_dev, and preview_urls. WorkersBuilds.Worker writes a setting after create only when
its props name it. If the Wrangler file names a setting, do not set it on the resource. Then the
two never write different values.
pnpm add -D @samebase/alchemy-cloudflare-workers-builds alchemy@2.0.0-beta.80 effect@^4.0.0 @effect/platform-node@^4.0.0The resources use the same Cloudflare credentials and account as Cloudflare.providers().
The Alchemy OAuth login has no Workers Builds scope. With the OAuth login alone, Cloudflare refuses
the calls, and the resources fail with WorkersBuildsPermissionError. Use a user-scoped API token
instead:
- Create a user API token in the Cloudflare dashboard with these permissions:
- Workers Builds Configuration: Edit
- Workers Scripts: Edit
- Account Settings: Read
- Set
CLOUDFLARE_API_TOKENto the token andCLOUDFLARE_ACCOUNT_IDto the account id.
Alchemy uses environment credentials only when both variables are set. They then replace the profile for the whole stack, so also give the token the permissions that the other resources in the stack need.
When the account has no build token and WorkersBuilds.Repository has no buildToken, the
provider registers this token as the build token (see Build token).
Workers Builds then deploys with it, so also give it the permissions of the deploy:
- Workers Scripts: Edit (in the list above)
- Workers R2 Storage: Edit, Workers KV Storage: Edit, and D1: Edit, when the Wrangler file binds R2 buckets, KV namespaces, or D1 databases
To register the token, the provider needs no other permission. If you roll or delete the token, the builds that deploy with it fail.
The Cloudflare Workers and Pages GitHub App must have access to the repository. Install it once from Workers & Pages in the Cloudflare dashboard.
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as WorkersBuilds from "@samebase/alchemy-cloudflare-workers-builds";
export default Alchemy.Stack(
"MyApp",
{
providers: Layer.mergeAll(Cloudflare.providers(), WorkersBuilds.providers()),
state: Cloudflare.state(),
},
Effect.gen(function* () {
// Without `name`, Alchemy makes one, such as `myapp-worker-dev-k3m7x2ab`.
const worker = yield* WorkersBuilds.Worker("Worker");
// Without `repository`, the repository of this clone or GitHub Actions
// run, built from its default branch.
const builds = yield* WorkersBuilds.Repository("Builds", {
worker: worker.workerId,
buildCommand: "pnpm install && pnpm run build",
variables: { CONVEX_DEPLOY_KEY: Config.Redacted("CONVEX_DEPLOY_KEY") },
previewVariables: { CONVEX_DEPLOY_KEY: Config.Redacted("CONVEX_PREVIEW_DEPLOY_KEY") },
});
// A Worker secret: the code reads it as env.API_KEY.
yield* WorkersBuilds.Secret("ApiKey", {
worker: worker.name,
name: "API_KEY",
value: Config.Redacted("API_KEY"),
});
return { url: worker.url, triggerIds: builds.triggerIds };
}),
);The run file names no Worker, repository, or id, so it works unchanged in each fork of the repository. Alchemy creates the Worker first, because the configuration and the secret use its outputs. The Worker has no version until Workers Builds builds the next push to the production branch.
WorkersBuilds.Repository then uses the repository of the run, in this order:
- In GitHub Actions,
GITHUB_REPOSITORY, through Alchemy'sGitHubEnvfromalchemy/GitHub. - Else the
originremote of the current directory (git remote get-url origin), in the formhttps://github.com/<owner>/<name>orgit@github.com:<owner>/<name>, with or without.git.
When neither gives a GitHub repository, the plan or the deploy fails with WorkersBuildsError.
The provider then reads the GitHub ids and, without repository.branch, the default branch from
one call to GET https://api.github.com/repos/{owner}/{name}. For a private repository, set
GITHUB_TOKEN. In GitHub Actions, pass GITHUB_TOKEN: ${{ github.token }} in env.
The plan reads the repository of the run. When the configuration builds from another repository,
the plan shows an update, and the deploy fails with WorkersBuildsError. The repository of the run
never moves the builds to another repository, because a clone of a fork must not take over the
production builds. To move the builds, pass repository.
WorkersBuilds.currentRepository is the same resolver, for names in the run file. It yields
{ owner, name, defaultBranch, ownerId, repositoryId }. A run file can fail only with
ConfigError, so pipe it through Effect.orDie:
Effect.gen(function* () {
const repository = yield* WorkersBuilds.currentRepository.pipe(Effect.orDie);
const worker = yield* WorkersBuilds.Worker("Worker", { name: repository.name });
// ...
});WorkersBuilds.Worker then makes the name with Alchemy's createPhysicalName, as other Alchemy
resources do: the stack name, the logical id, the stage, and 8 characters of the resource's
instance id, lowercase and at most 54 characters, such as myapp-worker-dev-k3m7x2ab.
- The name is made once, on create, and stays in state. Later deploys use the stored name. A replacement, such as another account, makes a new name.
- An explicit
namethat differs from the stored name replaces the Worker. Removingnamekeeps the Worker and its name. - On Wrangler 3 and later, Workers Builds deploys to the connected Worker
whatever
namethe Wrangler file has. - The name is also the
workers.devhostname:https://<name>.<account subdomain>.workers.dev. A production Worker usually wants an explicit name.
Destroy deletes the Worker with all of its versions and preview URLs. A stack that must keep a production Worker on destroy wraps the call in Alchemy's removal policy:
Effect.gen(function* () {
const worker = yield* WorkersBuilds.Worker("Worker", { name: "my-app" }).pipe(
Alchemy.RemovalPolicy.retain(),
);
// ...
});- Alchemy then does not delete the Worker. It removes the Worker from state, and the plan shows the
Worker as
orphaned. - This also applies to an adopted Worker, because Alchemy destroys adopted and created resources in the same way.
- A replacement, such as a new
name, then also keeps the old Worker. - The policy is not a prop, so the plan shows no change. The next deploy writes it to state. Deploy once after you add it, before you destroy.
- Destroy still deletes the
WorkersBuilds.RepositoryandWorkersBuilds.Secretresources of the Worker. Each deleted secret makes a new version of the Worker without that secret.retain()applies to each resource that the piped effect declares, so you can wrap all of them in oneEffect.genblock.
Calls POST, GET, PATCH, and DELETE /accounts/{account_id}/workers/workers[/{worker_id}]
and GET /accounts/{account_id}/workers/subdomain.
| Prop | Default | Change |
|---|---|---|
name |
made by Alchemy, see Without name |
replace |
subdomain |
{ enabled: true, previewsEnabled: true } |
update |
observability |
persisted invocation logs, no traces | update |
logpush |
false |
update |
tags |
[] |
update |
tailConsumers |
[] |
update |
Outputs: workerId (the Worker tag), name, url
(https://<name>.<account subdomain>.workers.dev), accountId, and each setting that the props
declare, as Cloudflare reports it. See What drift detection sees.
- Defaults apply on create only. An update sends only the props that you set.
- Destroy deletes the Worker with all of its versions, deployments, and preview URLs. A Worker that is already gone is not an error. To keep the Worker, see Keep the Worker on destroy.
- A new explicit
namecreates a new Worker, and then deletes the old Worker. - An existing Worker with the same name is adopted only with
--adopt. A made name is new, so nothing is adopted withoutname.
Calls GET, POST, PATCH, and DELETE /accounts/{account_id}/builds/workers[/{script_tag}],
POST .../builds/workers/{script_tag}/migrate_to_previews, GET and POST .../builds/tokens,
GET /user/tokens/verify, GET .../builds/workers/{script_tag}/triggers,
DELETE .../builds/triggers/{trigger_uuid}, and GET https://api.github.com/repos/{owner}/{name}.
| Prop | Default | Change |
|---|---|---|
worker |
required | replace, see Another Worker below |
repository |
the repository of the run | see Without repository |
repository.owner, .name |
required in repository |
update, see Renames below |
repository.branch |
default branch on GitHub | update |
repository.ownerId, .repositoryId |
read from GitHub | update, see Another repository below |
buildCommand |
required | update |
deployCommand |
npx wrangler deploy |
update |
previewDeployCommand |
npx wrangler preview |
update |
rootDirectory |
/ |
update |
pathIncludes |
["*"] |
update |
pathExcludes |
[] |
update |
buildCachingEnabled |
true |
update |
buildToken |
see below | update |
previews |
true |
update |
variables |
none | update |
previewVariables |
none | update |
Outputs: scriptTag, repoConnectionId, triggerIds, previewsEnabled, accountId,
repository (the repository that the configuration builds from, with its GitHub ids, and the
production branch), and production and preview (the build settings). See
What drift detection sees.
- No replacement for the same Worker: every change is an update of the one configuration. Workers Builds keeps one configuration per Worker, and Alchemy creates a replacement before it deletes the old resource. A replacement for the same Worker would update the configuration and then delete it.
- GitHub ids: Workers Builds addresses a repository by its numeric GitHub ids. Without
ownerId,repositoryId, andbranch, the provider reads them from the GitHub API withGITHUB_TOKENorGITHUB_ACCESS_TOKEN, or without a token for a public repository. The plan reads them once per deploy, and the deploy uses the same result. - Renames: GitHub keeps the repository id when a repository gets a new name or owner. The plan compares the id of the new name with the id that the configuration builds from. The same id is an update of the same configuration: one PATCH, and no delete. The props need no ids for this. Workers Builds keeps the old names in the configuration; the builds continue.
- Another repository: another repository id is also an update. The deploy deletes the build
triggers and the configuration, and then creates the configuration for the new repository, in
that order. The new configuration keeps the build token. Variables that someone else added are
gone. If the create fails, the next deploy creates the configuration with the saved build token.
Only an explicit
repositoryprop moves the builds to another repository. State from 0.4 has no saved build token, so the first move after the upgrade needsbuildToken, or one deploy without the move first. - A configuration that is gone or moved: the plan reads the configuration of the Worker. When it
is gone, or builds from another repository, such as after someone changed it or after a move or
repair that failed halfway, the plan shows an update, and the deploy completes it. Without
repository, the deploy creates a configuration again only for the repository in state. When the state has no saved build token (state from 0.4), it needsbuildTokenfor that. - Another Worker: another
workeror account replaces the resource. That is another configuration, so Alchemy creates it and then deletes the old one.Alchemy.RemovalPolicy.retain()keeps the old one. When the same deploy replaces theWorkersBuilds.Worker, the plan does not know the new Worker tag yet. Then the deploy updates the resource: it creates the configuration of the new Worker and does not delete the configuration of the old Worker. Alchemy then deletes the old Worker, unless it is retained. - Build token: Workers Builds deploys with the API token behind a build token. Without
buildToken, a new configuration uses the account's first build token (by name, newest first), and an existing configuration keeps its token. If the account has no build token, the provider registers the stack's API token (CLOUDFLARE_API_TOKEN) as a build token namedalchemy-<stack name>, with the token id fromGET /user/tokens/verify. The token then needs the permissions of the deploy, see Credentials. Destroy keeps the build token, because build tokens belong to the account and other configurations can use them. Workers Builds accepts only API tokens: with the OAuth login or a global API key, the provider cannot register one, and the deploy fails withWorkersBuildsError. - Previews: with
previews: true, Cloudflare builds a preview deployment with its own URL for each branch. Cloudflare can create legacy branch triggers even when the request enables previews. The provider then callsmigrate_to_previews. - Variables:
variablesapply to production and preview builds.previewVariablesreplace keys ofvariablesin preview builds.Redactedvalues are written as secrets, which Cloudflare never returns. A key that you remove is removed on the next deploy. Variables that someone else added stay. - Destroy removes the build triggers and the build configuration. It keeps the Worker and the repository connection, because every Worker that builds from the same repository shares that connection.
- An existing configuration for the Worker is adopted only with
--adopt. If an adopted configuration builds from another repository, the deploy fails withWorkersBuildsError. The provider moves only a configuration that it wrote.
Calls PUT and GET /accounts/{account_id}/workers/scripts/{script_name}/secrets and
DELETE .../workers/scripts/{script_name}/secrets/{secret_name}.
| Prop | Default | Change |
|---|---|---|
worker |
required | replace |
name |
required | replace |
value |
required | update |
Outputs: workerName, name, accountId.
- Worker: these endpoints address the Worker by its name. Pass
worker.nameof aWorkersBuilds.Worker. The Worker must exist. If it does not, the write fails withWorkersBuildsError. - Value: a
Redactedstring, such asConfig.Redacted("API_KEY"). Cloudflare never returns a value, so the provider reads only the names. A new value is written on the next deploy. - Versions: each write and each delete creates a new version of the Worker. Wrangler keeps secrets on deploy, so the next build keeps the secret.
- Destroy deletes the secret. A secret or Worker that is already gone is not an error.
- An existing secret with the same name is adopted only with
--adopt. The first deploy after--adoptwrites the value.
alchemy drift and alchemy deploy --detect-drift read each resource from Cloudflare and compare
the result with the attributes in state. A difference is drift. The repair writes the props of the
last deploy again.
WorkersBuilds.Repository: the repository and its GitHub ids, the production branch, the triggers, the repository connection, Worker Previews, and for production and preview builds: the build command, the deploy command, the root directory, the path filters, build caching, the build token, and the names of the variables, each"secret"or"plain".WorkersBuilds.Worker: only the settings that the props declare:subdomain,observability(each field that Cloudflare reports),logpush,tags, andtailConsumers. A setting that the props leave out is not drift, because the Wrangler file can own it.WorkersBuilds.Secret: only that the name exists.
Variable values are not in the attributes. Cloudflare never returns a secret value, so a changed
secret value cannot be seen. The attributes also show in plans and drift reports, so they hold no
value. Thus a changed value is not drift. A removed variable, an added variable, and a variable
that changed between plain and secret are drift. To write all values again, run
alchemy deploy --force.
The repair keeps a variable that someone else added, as a deploy does, and saves its name in the attributes. The next drift check then shows no drift.
The first deploy after an upgrade from 0.4 is an update of each WorkersBuilds.Repository, and of
each WorkersBuilds.Worker that declares a setting. It saves the new attributes.
- GitHub only. Workers Builds also supports GitLab; this package does not.
- The package never creates an API token. It registers only the stack's own API token as a build token. No live run has registered a build token yet.
- Secret build variables and
WorkersBuilds.Secretvalues are part of Alchemy state. Alchemy's Cloudflare state store encrypts state at rest; the local file store does not. - Turning
previewsfromtruetofalsesendspreviews_enabled: false. The live tests do not cover this case yet. - A secret on a Worker that has no version yet, such as a new shell before its first build, is not
proven.
secret.live.test.tscovers this case. It has not run yet.
pnpm install
pnpm run check # format, lint, typecheck, unit tests
pnpm run build # lib/ with declarations, then publint and attw
pnpm run test:live # real API calls, see belowUse Node 24 (.node-version) and pnpm 11. pnpm install also installs a pre-commit hook that
runs vp staged.
Unit tests run against recorded Cloudflare and GitHub payloads in test/fixtures/, and against
secret and build token payloads that follow Cloudflare's API schema until a live run records them.
They make no network calls.
Live tests run only with ALCHEMY_WORKERS_BUILDS_LIVE=1, which pnpm run test:live sets:
permission.live.test.tsneeds only the network. It runs a plan through the Alchemy engine with a placeholder token and expectsWorkersBuildsPermissionError.workers-builds.live.test.tsneedsCLOUDFLARE_API_TOKENwith the permissions above and a GitHub repository that the Cloudflare GitHub App can read. The defaults are intest/live/env.ts. It creates resources namedtmp-alchemy-workers-builds-*and removes them again.secret.live.test.tsneedsCLOUDFLARE_API_TOKENwith the permissions above and no GitHub repository. It creates a Worker shell namedtmp-alchemy-workers-builds-secret-*, writes, updates, and deletes a secret on it, and deletes the Worker.
Releases: change version in package.json in a pull request. After the merge,
.github/workflows/release.yml publishes that version from main with npm trusted publishing.
Apache 2.0.