EN FR

Chapter 14Ship

GitHub, end to end

Repository, machine account, fine-grained token, variables, CI, deployment, checks. The whole path, with no step left implied.

7 min read13 sectionsChapter 14 / 22

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

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

  2. Private

    The content is public on the site, but the repository holds history, addresses and configuration. Nothing requires exposing it.

  3. One publishing branch

    main by default. It is what GIT_BRANCH declares, and what the host rebuilds.

  4. 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 main

    Check that .dev.vars did not go along: it is in .gitignore, but an unfortunate git add -f happens.

Branch protection: careful

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:

  1. Settings → Developer settings → Personal access tokens → Fine-grained tokens

  2. Generate new token

    • Resource owner: the account or organisation that owns the repository.
    • Repository access: Only select repositories → the client repository, only.
    • Repository permissions: ContentsRead and write. Everything else on No access.
    • Expiration: the shortest your operations can live with.
  3. Copy the token

    It is shown once. It goes to the host, never into the repository, never into an email.

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

Why Contents and nothing else

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

on the host — as runtime secrets env
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.

OperationCallWhat 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 responseTranslated toSeen by the client
404not_found“This change could not be saved”
401 / 403unauthorizedsame
409 / 422conflict“Someone else published while you were working”
otherunavailable“This change could not be saved”

6. Test the provider before anything else

from the reference repository bash
# 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 --write

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

.github/workflows/ci.yml yaml
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
No secret is needed here

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.

  1. Create the project on the host, wired to the GitHub repository

    Build command: npm run build. Published folder: dist.

  2. Set the variables as runtime secrets

    Not as build variables: they must never reach the browser.

  3. Declare the RATE_LIMIT key-value binding

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

.github/workflows/deploy.yml yaml
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 does not fit

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" → 1
  • curl -s -X POST https://the-site.com/api/save → 401
  • Five wrong keys in a row on /admin → wait message.
  • A change published from /admin shows up as a commit in the repository, attributed to EDITOR_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

SymptomMost frequent cause
Publish refused, logs say not_foundGIT_REPO mistyped, branch missing, or the token has no access to that repository.
Logs say unauthorizedToken expired, revoked, or missing the Contents permission.
Logs say conflict every timeThe file changes between read and write — another process is writing to the repository.
The commit lands, the site does not changeThe host is not watching that branch, or the build fails. Check the build log.
The commit is rejectedBranch protection: mandatory review or required check on main.
Everything worked, nothing works nowToken expiry. The most mundane failure of this setup.
API quota

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.