Site Publish
Site Publish — site-publish/1 and bob site publish
Section titled “Site Publish — site-publish/1 and bob site publish”One publisher, many producers (#3103, decided on #3098). (Back to reference index.)
The webmaster role
Section titled “The webmaster role”The operator’s static site repo is the webmaster: the only path to the
site. For this operator that is paulirv/iwpi-static, served at
https://www.iwpi.com. A producer makes pages. bob brief is one
producer, bob release notes site (#3060) is another, and proposals will be others. A producer never
clones, commits to or deploys the site. It hands the publisher a bundle,
and the publisher owns everything past that point:
| The publisher owns | So no producer carries |
|---|---|
| the target repo, branch and site root | its own clone or checkout path |
| each section’s access (Access-gated or public) and the probe of every served host (#2895) | its own gate |
| the Cloudflare account the deploy runs as (#2984) | its own CLOUDFLARE_ACCOUNT_ID |
| the clean-checkout guard, the commit, the push and the deploy | its own wrangler call |
The engine is scripts/site/publish.js. scripts/brief/publish.js is the
brief producer: it builds a bundle and calls the engine. Its pages are
generated in the operator repo’s www/bob/briefings/ (#4263), never in the BoB
source checkout, because they name the operator’s projects and clients. A
release tag of BoB therefore never ships them, and the bundle’s
source_commit is the operator repo’s HEAD.
The bundle — site-publish/1
Section titled “The bundle — site-publish/1”A bundle is a directory holding bundle.json and the files it names
(schemas/site-bundle.schema.json):
{ "schema": "site-publish/1", "section": "bob/briefings", "producer": "bob brief", "source_commit": "8d3abb3505c3…", "generated_at": "2026-09-24T12:00:00Z", "files": ["index.html", "briefing.css", "backlog-reduction-2026-09-24.html"]}| Field | Meaning |
|---|---|
schema |
Always site-publish/1. |
section |
The section path under the site root. It must be declared in the site. Lower-case segments, no leading slash, no ... |
producer |
The command that made the files. It is named in the commit. |
source_commit |
The producer checkout’s HEAD, or null outside a git checkout. |
generated_at |
ISO 8601. |
files |
Paths relative to the bundle directory and to the section. Nested paths are allowed. |
Section confinement. Every file lands at <root>/<section>/<file>. A file
that is absolute or climbs out with .. is refused with exit 2 before
anything is read from the site. So one producer can never overwrite another’s
pages or the site root.
The site — config/site-publish.json
Section titled “The site — config/site-publish.json”The operator declares the site once, in the operator repo
(schemas/site-publish.schema.json, reached through
operatorPath('site-publish.json')):
{ "_meta": { "version": 1 }, "target": { "repo": "paulirv/iwpi-static", "url": "git@github.com:paulirv/iwpi-static.git", "branch": "main", "root": "public" }, "served_url": "https://www.iwpi.com", "sections": { "bob/briefings": { "gated": true, "access_application": "iwpi.com", "producer": "bob brief" }, "bob/releases": { "public": true, "producer": "bob release" } }, "deploy": { "command": ["npx", "wrangler", "pages", "deploy", "./public", "--project-name=iwpi"], "cloudflare_account": "default" }}| Key | Meaning |
|---|---|
target.repo, url, branch |
The repo that holds the served tree, and the branch it is published from. |
target.root |
The directory inside the repo that is the site root. Optional; the default is the repo root. |
target.checkout |
The local clone to publish from. Default: $BOB_HOME/cache/site-publish/<name>. |
served_url |
The site’s origin, with no path. A section is served at <served_url>/<section>/. |
hosts |
Further hostnames the site answers on. They are probed for every gated section. |
sections.<path>.gated + access_application |
The section is private: it must be behind the named Access application on every host. |
sections.<path>.public |
The section is deliberately public. |
sections.<path>.producer |
Who makes it. bob brief uses this to find its section. |
deploy.command |
argv, run from the checkout root. |
deploy.cloudflare_account |
A 32-hex account id, or a key into config/cloudflare-accounts.json. It is exported as CLOUDFLARE_ACCOUNT_ID for the deploy. |
A section that is neither gated with an application nor public is refused.
The publisher is fail-closed.
The brief-publish.json fallback. Until 2026-12-31, when no
config/site-publish.json exists, the older config/brief-publish.json
(#2872) is read and mapped onto one gated section. Its target.path splits
into the site root and the served_url path. --publish prints a warning
that names the file to declare and the retirement date.
bob site publish
Section titled “bob site publish”bob site publish <bundle-dir> [--apply] [--deploy] [--force-live] [--json]Without --apply it reports and writes nothing: each file is added,
updated or unchanged against the target’s origin/<branch> by git blob
hash. A file under the section with no bundle file is orphaned: it is
reported and never deleted. The report also shows the gate probes and the
deploy account.
With --apply it checks, in order, and each refusal comes before anything is
written:
- The bundle is a valid
site-publish/1bundle whose files stay inside the section (exit 2 otherwise). - The section is declared, and it is either gated with an Access application or public.
- Every host serves a gated section behind Access (#2895). The probe
covers the
served_urlhost, itswww/apex twin, everyhostsentry, and, for awrangler pages deploy --project-name=<p>,<p>.pages.devand a deployment alias no deploy produced. Each must answer with a redirect to*.cloudflareaccess.com. Anything else isEXPOSED(error codeexposed). - The deploy account resolves (#2984). With none declared and a wrangler
login spanning more than one account, the refusal comes here, before the
push (error code
account). - The live site is already on
origin/<branch>(#4418). Awrangler pages deployreplaces the whole site, so the publisher reads the newest production deployment (wrangler pages deployment list --json) and the commit Cloudflare recorded as its source. That commit must be onorigin/<branch>. If it is not, someone deployed from a checkout that never pushed, and this deploy would erase what they added (error codelive-ahead, naming the deployment and the commit). Push that commit, then re-run. When the live commit cannot be read, the publisher refuses too (error codelive-unknown).--force-livedeploys anyway. A project that has never been deployed goes ahead. - The checkout is clean and on the branch. Untracked
.wrangler/residue is ignored (#3018).
Then it copies the added and updated files, commits (site: publish <section> — N added, M updated, naming the producer and its commit), pushes and runs the
deploy. If nothing changed, nothing is committed or deployed; --deploy
redeploys anyway.
Exit codes: 0 reported / published / unchanged · 2 usage, an invalid
bundle or a file outside its section · 3 no site, an invalid site, a
refusal or a git failure · 4 the deploy failed after the push landed (the
commit stays; --apply --deploy is the recovery).
To add a producer, see Add a Site Producer.