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:
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.