Cloudflare Workers Builds: publish a static site on a GitHub Release, with no token in GitHub

In short. Connect the Worker to the repository with Workers Builds and make production the only branch it builds. Turn preview builds off. Have the build write its commit into /version.txt. A workflow that runs only when a release is published checks the commit is on main, force-pushes it to production, and polls /version.txt until the live site serves it. GitHub holds no Cloudflare credential, a merge never publishes, and rolling back is a release on an older commit.

A deploy token in GitHub broke, so GitHub no longer holds one

The first version of this site's pipeline deployed from GitHub Actions with a narrow Cloudflare token stored as an environment secret. It started failing with a 401 the day old tokens were tidied away in the dashboard, and two pasted replacements failed the same way. Rather than work out which token the secret had really held, the deploy moved to Cloudflare's side. Cloudflare builds and deploys; GitHub only decides when. The design post covers why. This is the checklist.

flowchart TB
  R["Author publishes a release"] --> C{"Is the commit<br/>on main?"}
  C -- "no" --> X["Run fails,<br/>nothing moves"]
  C -- "yes" --> P["Workflow force-pushes it<br/>to the production branch"]
  P -- "push event,<br/>Cloudflare's GitHub app" --> B["Workers Builds:<br/>bun run build,<br/>writes /version.txt"]
  B -- "wrangler deploy,<br/>tagged with the commit" --> L["Live site"]
  L -- "workflow polls /version.txt<br/>every 20 s, up to 20 min" --> G["Run goes green:<br/>the release is live"]

What you need

Steps

1. Make the build say which commit it is

Add a small script that writes the commit into the output, and run it last in the build:

"build": "next build && pagefind --site out && node scripts/write_version.mjs"
// scripts/write_version.mjs: runs on Node and Bun
import { execSync } from "node:child_process";
import { writeFileSync } from "node:fs";

function commit() {
  const fromEnv = process.env.WORKERS_CI_COMMIT_SHA || process.env.GITHUB_SHA;
  if (fromEnv) return fromEnv.trim();
  try {
    return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
  } catch {
    return "unknown";
  }
}

writeFileSync("out/version.txt", `${commit()}\n`);

Workers Builds sets WORKERS_CI_COMMIT_SHA to the commit it is building. The file is public and harmless, and it is the only thing the release workflow needs to read to know the release is live. No credential involved.

2. Describe the Worker in wrangler.jsonc

{
  "name": "my-blog",
  "compatibility_date": "2026-10-01",
  "assets": {
    "directory": "./out",
    "not_found_handling": "404-page"
  },
  "routes": [{ "pattern": "blog.example.com", "custom_domain": true }]
}

The name must match the Worker you connect in step 4. There is no Worker script: the static assets are the whole site, and 404-page serves the 404.html that Next.js generates.

3. Create the production branch once

git push origin main:refs/heads/production

From now on only the release workflow pushes to it.

4. Connect Workers Builds

In the Cloudflare dashboard: Workers & Pages, the Worker, Settings, Builds, Connect.

5. Pin the runtime version

On the same page, under Variables and secrets for production, add a variable BUN_VERSION with the version you develop with, here 1.4.2. The build image ships an older Bun, and without this the first build failed at install:

Detected the following tools from environment: bun@1.2.15, nodejs@24.18.0
error: Unknown lockfile version
error: lockfile had changes, but lockfile is frozen

Set it, then Retry build on the failed build. When you upgrade Bun in the repository, change this variable in the same breath.

6. Turn preview builds off

Still under Settings, Builds, switch to the Previews tab and turn off Builds for Preview branches. It is on by default and builds every other branch. Its variables are separate from production's, so it ran the old Bun and put a red "Workers Builds" check on every pull request. Even when such builds pass, they upload versions nobody released. Changing Branch control on the Production tab does not touch this switch.

On the Domains tab, leave the workers.dev and preview addresses off, so the custom domain is the only way in.

7. Restrict a GitHub environment to release tags

In the repository's Settings, Environments, create production. Under Deployment branches and tags, choose selected branches and tags and add a tag rule v*. Put no secrets in it. The environment exists for the rule: only a job running from a release tag can use it.

8. Add the release workflow

# .github/workflows/deploy.yml
name: deploy

on:
  release:
    types: [published]

permissions:
  contents: write # to move the production branch; nothing else

concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  deploy:
    name: publish ${{ github.event.release.tag_name }}
    if: github.event.release.prerelease == false
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # to check that the released commit is on main
      - name: Refuse a commit that is not on main
        run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main
      - name: Move the production branch to the released commit
        run: git push --force origin "$GITHUB_SHA:refs/heads/production"
      - name: Wait until the live site serves the released commit
        run: python3 scripts/wait_for_live.py "$GITHUB_SHA"

9. Add the wait script

#!/usr/bin/env python3
# scripts/wait_for_live.py <commit-sha>: exit 0 once the live site serves the commit
import sys, time, urllib.error, urllib.request

SITE = "https://blog.example.com"
AGENT = "my-blog release check (+https://blog.example.com)"

def live_version() -> str:
    url = f"{SITE}/version.txt?t={int(time.time())}"  # a fresh URL each time, past any cache
    req = urllib.request.Request(url, headers={"User-Agent": AGENT, "Cache-Control": "no-cache"})
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return resp.read().decode("utf-8", "replace").strip()
    except urllib.error.URLError as e:
        return f"(no answer: {getattr(e, 'reason', e)})"

sha, deadline = sys.argv[1].strip().lower(), time.time() + 1200
while time.time() < deadline:
    seen = live_version()
    if len(sha) == 40 and seen.lower() == sha:
        print(f"live: {SITE} serves {sha}")
        sys.exit(0)
    print(f"waiting: live is {seen[:40]}, want {sha}", flush=True)
    time.sleep(20)
sys.exit("timed out: check the Workers Builds log in Cloudflare")

Name your check in the user agent; some zones challenge Python's default one. The same function makes a read-only status check: compare live_version() with git rev-parse origin/main and print in sync or behind.

10. Delete what you no longer need

Then add a test so nobody quietly brings one back:

from pathlib import Path

def test_no_workflow_holds_a_cloudflare_credential_or_moves_production():
    for path in Path(".github/workflows").glob("*.yml"):
        text = path.read_text()
        assert "CLOUDFLARE_API_TOKEN" not in text, path.name
        assert "wrangler deploy" not in text, path.name
        if path.name != "deploy.yml":
            assert "refs/heads/production" not in text, path.name

11. Publish

Create a release on main from the web, the mobile app or the command line:

gh release create v2026.10.08 --target main --generate-notes

A second release on the same day is v2026.10.08.1.

Verify it worked

  1. The release run goes green, ending with live: https://blog.example.com serves and the commit.

  2. The live file matches:

    curl -s https://blog.example.com/version.txt
    
  3. The Worker's Deployments tab shows a build of production at that commit, and the new version carries the commit as its tag.

  4. A pull request shows only your own CI checks, with no "Workers Builds" check.

Roll back

For a considered rollback, create a release whose tag points at the last good commit, for example v2026.10.08.2 on an older commit of main. The workflow accepts it because the commit is on main, force-pushes production backwards, and Cloudflare builds it like any other release. I have not needed this one yet; nothing in the chain cares which direction the branch moves, but try it once on a quiet day before you need it. In a hurry, the Worker's Deployments tab can roll back to a previous version straight away; the next release then puts the branch and the site back in step.

Gotchas

FAQ

Is Cloudflare's build token not just a token somewhere else? It is a token, but Cloudflare creates it, scopes it, stores it and uses it. No person copies it, and it never appears in a GitHub secret, a log or a screenshot. The failure that started this, a person handling the wrong token, cannot happen.

Why not let Cloudflare build main on every merge? Then every merge is a publish. The production branch is the release gate: Cloudflare builds whatever lands there, and only a published release puts anything there.

Does this work with Node instead of Bun? Yes. Use npm run build as the build command, and set NODE_VERSION instead of BUN_VERSION if the image's default does not suit.

What does it cost? Nothing extra on the free plan for a blog: one build per release, well inside the free build minutes.

Related