Appearance
Site Publish — site-publish/1 and bob site publish
One publisher, many producers (#3103, decided on #3098). (Back to reference index.)
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
A bundle is a directory holding bundle.json and the files it names (schemas/site-bundle.schema.json):
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
The operator declares the site once, in the operator repo (schemas/site-publish.schema.json, reached through operatorPath('site-publish.json')):
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
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.