newerkey notes
// devops · 1 July 2026 · 9 min read · updated 1 July 2026

Deploy Pipeline for a Static Site

How this site ships: two branches, one workflow file, GitHub Actions as the source of truth, and Cloudflare Workers as the target. Now with a self-opening PR and a branch protection rule that blocks direct pushes to main. Includes the errors I hit getting there.

newerkey github-actions cloudflare-workers wrangler ci-cd deploy-pipeline

Before this site had a single blog post migrated, it needed a way to ship changes safely. Not a CI service clicking buttons on my behalf — a pipeline I could read in one file and reason about in one pass.

Here’s what I built, why it’s shaped the way it is, and the two mistakes that got fixed along the way.

Why GitHub Actions instead of Cloudflare’s built-in CI

Cloudflare Pages and Workers can both watch a connected GitHub repo and build on every push, no workflow file required. I didn’t use that.

The reason is ownership. When Cloudflare owns the build, the build logic — install, build, deploy — lives in Cloudflare’s dashboard, outside version control. When GitHub Actions owns it, the entire pipeline is a file in the repo: reviewable in a pull request, diffable in git log, portable if I ever move providers.

For a one-person studio, “portable and reviewable” beats “one less file to write.”

The flow

Two branches, two environments, one workflow:

git push staging branch GitHub Actions install + build wrangler deploy --env staging newerkey-notes-staging Worker PR opens itself auto-pr workflow you verify looks good → merge the PR (direct push to main is blocked) merge PR main branch GitHub Actions install + build wrangler deploy production newerkey-notes Worker notes.newerkey .com cleanup workflow deletes old runs

Push to staging, the workflow builds and deploys to a separate staging Worker at its own *.workers.dev URL. That deploy’s success triggers a second workflow that opens a pull request into main on my behalf — I don’t create it. I check the staging URL, and if it’s good, I merge the PR. That merge is the only way anything reaches main: a branch protection rule rejects direct pushes and force-pushes, so the pipeline can’t be bypassed by forgetting a step. Merging fires the same deploy workflow again, this time against production at notes.newerkey.com. No manual wrangler deploy from my laptop, ever, and no manual gh pr create either.

The workflow file

name: Deploy

on:
  push:
    branches:
      - main
      - staging

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    name: Build and Deploy

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Cache node_modules
        id: cache-modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - name: Install dependencies
        if: steps.cache-modules.outputs.cache-hit != 'true'
        run: npm ci

      - name: Build
        run: npm run build

      - name: Deploy to Cloudflare (staging)
        if: github.ref_name == 'staging'
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          wranglerVersion: '4'
          command: deploy --env staging

      - name: Deploy to Cloudflare (production)
        if: github.ref_name == 'main'
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          wranglerVersion: '4'
          command: deploy

One job, one file, two conditional deploy steps gated on branch name. Which environment gets a build is a github.ref_name check, not a separate pipeline.

Where it broke

Wrong working directory. My first draft assumed the Astro project lived in a notes-site/ subfolder of the repo, so every step had working-directory: notes-site and the cache step pointed at notes-site/package-lock.json. The repo root is the Astro project — there’s no subfolder. The build failed immediately:

##[error]Some specified paths were not resolved, unable to cache dependencies.

The fix was deleting every notes-site/ prefix from the workflow. A good reminder to check git rev-parse --show-toplevel before writing paths into CI, instead of assuming the layout.

Wrangler falling out of date. A few days after the pipeline was live, a run logged this:

▲ [WARNING] The version of Wrangler you are using is now out-of-date.
Please update to the latest version to prevent critical errors.
Run `npm install --save-dev wrangler@4` to update to the latest version.

wrangler-action doesn’t pin a Wrangler version by default — it uses whatever’s current when the action last shipped. Adding wranglerVersion: '4' to both deploy steps pins it explicitly, so the next Wrangler major version doesn’t silently change my deploy behavior.

Closing the gap: a self-opening PR and a branch that can’t be pushed to

The pipeline above still had a manual step: after staging deployed, I had to remember to run gh pr create myself. That’s exactly the kind of step that gets skipped under time pressure, so I closed it with two additions.

A workflow that opens its own PR. It listens for the Deploy workflow completing on staging, and if there isn’t already an open PR into main, it creates one:

name: Auto PR to main

on:
  workflow_run:
    workflows: ["Deploy"]
    branches: [staging]
    types: [completed]

permissions:
  contents: read
  pull-requests: write

jobs:
  open-pr:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    name: Open PR from staging to main

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Open PR if none exists
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          existing=$(gh pr list --base main --head staging --state open --json number --jq length)
          if [ "$existing" -eq 0 ]; then
            gh pr create \
              --base main \
              --head staging \
              --title "Promote staging to main" \
              --body "Auto-opened after a successful staging deploy. Check the staging Worker before merging."
          else
            echo "Open PR from staging to main already exists, skipping."
          fi

The existing check makes it idempotent — pushing three commits to staging in a row doesn’t open three PRs, it just leaves the one open PR alone. Same workflow_run + conclusion == 'success' pattern as the cleanup workflow that deletes old Action runs, just pointed at a different follow-up action.

A branch protection rule that blocks direct pushes. The workflow above only removes the habit of pushing straight to main — nothing stops a stray git push origin main from actually working. GitHub’s branch protection API closes that:

cat <<'EOF' | gh api repos/NewerKey/newerkey-notes/branches/main/protection -X PUT --input -
{
  "required_status_checks": null,
  "enforce_admins": false,
  "required_pull_request_reviews": {
    "required_approving_review_count": 0
  },
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false
}
EOF

My first attempt sent the same fields as -f flags instead of a JSON body, including -f 'restrictions=null' and -f 'required_pull_request_reviews[required_approving_review_count]=0'. gh api -f always sends string values, so null arrived as the literal string "null" and the nested bracket syntax never assembled into a real object. The API rejected it:

No subschema in "anyOf" matched.
For 'allOf/0', "null" is not an object.
For 'properties/required_approving_review_count', "0" is not an integer.

The fix was piping an actual JSON document through --input - instead of trying to construct nested JSON out of flag syntax. Past a certain nesting depth, -f isn’t the right tool — write the JSON.

With that rule active, main only changes through a merged pull request. Direct pushes and force-pushes both fail at the GitHub level, regardless of who or what tries them.

The principle

The pipeline should be one file you can read start to finish, not a setting you have to remember exists.

Neither fix was complicated. Both were fast to make because the entire pipeline is 45 lines in one file, in version control, with a diff for every change. That’s the actual return on writing it this way — not that it’s clever, but that it’s cheap to debug.


Part of my DevOps series — documenting real tasks with the commands, the reasoning, and the principles behind each decision.

newerkey github-actions cloudflare-workers wrangler ci-cd deploy-pipeline

// newerkey notes

Engineering notes on Linux, infrastructure, automation, and platform systems — written as I learn and build.

about these notes