Chapter 14Ship
GitHub, end to end
Repository, machine account, fine-grained token, variables, CI, deployment, checks. The whole path, with no step left implied.
Two roles not to confuse
GitHub is used here for two different things, and it helps to keep them apart:
Role 1
Host the code and trigger builds
The site repository, continuous integration, and the connection to the host that rebuilds on every push.
Role 2
Act as the content database
The /api/save function writes the JSON files through the Contents API, with the agency token. That is where GIT_TOKEN comes in.
Only the second role needs a token. A site can perfectly well be deployed from GitHub without the function writing to it — but then it is not editable.
1. The repository
-
One repository per client
One client's content must never sit next to another's: that is also what confines an incident. Explicit name:
agency/martin-bakery. -
Private
The content is public on the site, but the repository holds history, addresses and configuration. Nothing requires exposing it.
-
One publishing branch
mainby default. It is whatGIT_BRANCHdeclares, and what the host rebuilds. -
First push
bash bash cd martin-bakery git init -b main git add . git commit -m "Site setup" git remote add origin git@github.com:agency/martin-bakery.git git push -u origin mainCheck that
.dev.varsdid not go along: it is in.gitignore, but an unfortunategit add -fhappens.
If you protect main by requiring a review or a passing check before merge,
the client's publishes will be rejected: the function writes straight to the
branch. Two options — do not require a review on that branch, or explicitly allow the machine
account to bypass the rule. Test it before delivery, not after.
2. The machine account
The write token must belong to nobody. If it belongs to a developer, it leaves with them, and its scope is that of all their repositories.
- Create a GitHub account dedicated to the agency (say
agency-bot), with a service email address and two-factor authentication. - Invite it to the client repository with the Write role, and nothing more.
- The token is created from that account.
In an organisation you can also use a fine-grained organisation token: the principle is the same — an identity that is not a person, limited to one repository.
3. The fine-grained token
From the machine account:
-
Settings → Developer settings → Personal access tokens → Fine-grained tokens
-
Generate new token
- Resource owner: the account or organisation that owns the repository.
- Repository access: Only select repositories → the client repository, only.
- Repository permissions: Contents → Read and write. Everything else on No access.
- Expiration: the shortest your operations can live with.
-
Copy the token
It is shown once. It goes to the host, never into the repository, never into an email.
-
Note the expiry date
An expired token does not break the site — it breaks publishing, and the client discovers it while trying to change a page. Set a reminder a week ahead.
The function does two things: read a file and write one. It needs neither issues, nor actions, nor members, nor repository settings. A wider token brings nothing and costs everything the day it leaks.
4. The variables
GIT_PROVIDER=github
GIT_REPO=agency/martin-bakery
GIT_BRANCH=main
GIT_TOKEN=github_pat_…
EDITOR_KEY_HASH=$argon2id$v=19$m=19456,t=2,p=1$…
SESSION_SECRET=…
EDITOR_NAME=Site editor
EDITOR_EMAIL=contact@martin-bakery.com
GIT_REPO is written owner/repo, without https:// or
.git. EDITOR_NAME and EDITOR_EMAIL become the commit
author: that is what the client will see in the history, so make it readable.
5. What the function calls
Two Contents API endpoints, and only two. Worth knowing in order to read an error log.
| Operation | Call | What is taken from it |
|---|---|---|
| Read | GET /repos/{repo}/contents/{path}?ref={branch} |
The content (base64) and the blob SHA, used as the optimistic lock version. |
| Write | PUT /repos/{repo}/contents/{path} |
Message, content, expected sha, branch, author. GitHub itself refuses a stale sha. |
So the optimistic lock is applied twice: by the function, which compares the version
before writing, and by GitHub, which rejects a sha that no longer matches.
| GitHub response | Translated to | Seen by the client |
|---|---|---|
404 | not_found | “This change could not be saved” |
401 / 403 | unauthorized | same |
409 / 422 | conflict | “Someone else published while you were working” |
| other | unavailable | “This change could not be saved” |
6. Test the provider before anything else
# Offline: the mechanics, without the network
npm run test:git
# Real read against a real repository
GIT_REPO=agency/martin-bakery GIT_TOKEN=github_pat_… \
node scripts/test-git-provider.mjs --online
# Real write + provoked conflict (writes a test file into the repository)
GIT_REPO=agency/martin-bakery GIT_TOKEN=github_pat_… \
node scripts/test-git-provider.mjs --online --writeIt is the fastest way to know whether a token has the right rights: fifteen seconds against a full deployment.
7. Continuous integration
The shipped workflow runs exactly the commands you run before committing, and it blocks the branch.
name: Checks
on:
push:
branches: [main]
pull_request:
# A push replacing another cancels the previous run.
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test # builds the site along the way
- run: npm run check # raw HTML, languages, logs, secrets
The checks touch neither the content repository nor the write token: they build the site and
inspect it. permissions: contents: read says so explicitly.
The reference repository adds a second job, scaffold, which creates a site from
scratch, installs, builds and checks it. Without it, the template embedded in
create-inline diverges silently, and you only find out with the next client.
8. Deployment
The site is static and the functions live in /functions. Two ways to put it online
from GitHub.
A. Connect the repository to the host (recommended)
-
Create the project on the host, wired to the GitHub repository
Build command:
npm run build. Published folder:dist. -
Set the variables as runtime secrets
Not as build variables: they must never reach the browser.
-
Declare the
RATE_LIMITkey-value binding -
Verify
See Deployment for the full list.
Every client publish produces a commit on main; the host detects it and rebuilds.
That is the complete loop, and it requires no webhook to be written.
B. Deploy from GitHub Actions
Useful if you want to control the order: checks first, deployment after. The content write token has no business here — only the host credentials are needed.
name: Deployment
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- run: npm run check
- name: Publish
run: npx wrangler pages deploy dist --project-name=martin-bakery
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
GitHub Pages serves static files and does not run functions. The site would
display perfectly and /api/* would answer 404: no editing possible. You need a
host able to run /functions.
9. Post-launch checks
curl -s https://the-site.com/ | grep -c "a page title"→ 1curl -s -X POST https://the-site.com/api/save→ 401- Five wrong keys in a row on
/admin→ wait message. - A change published from
/adminshows up as a commit in the repository, attributed toEDITOR_NAME. - The site is rebuilt within a minute of that commit.
- The commit message looks like
content(fr): home — hero.title.
When it does not work
| Symptom | Most frequent cause |
|---|---|
Publish refused, logs say not_found | GIT_REPO mistyped, branch missing, or the token has no access to that repository. |
Logs say unauthorized | Token expired, revoked, or missing the Contents permission. |
Logs say conflict every time | The file changes between read and write — another process is writing to the repository. |
| The commit lands, the site does not change | The host is not watching that branch, or the build fails. Check the build log. |
| The commit is rejected | Branch protection: mandatory review or required check on main. |
| Everything worked, nothing works now | Token expiry. The most mundane failure of this setup. |
An authenticated account gets 5,000 GitHub API calls per hour. An editing session uses a few dozen. The route rate budgets are sized well below that: the quota is a non-issue in normal use, and becomes one if a script runs away — hence the budgets.