Skip to content

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

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

  1. The bundle is a valid site-publish/1 bundle whose files stay inside the section (exit 2 otherwise).
  2. The section is declared, and it is either gated with an Access application or public.
  3. Every host serves a gated section behind Access (#2895). The probe covers the served_url host, its www/apex twin, every hosts entry, and, for a wrangler pages deploy --project-name=<p>, <p>.pages.dev and a deployment alias no deploy produced. Each must answer with a redirect to *.cloudflareaccess.com. Anything else is EXPOSED (error code exposed).
  4. 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).
  5. The live site is already on origin/<branch> (#4418). A wrangler pages deploy replaces 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 on origin/<branch>. If it is not, someone deployed from a checkout that never pushed, and this deploy would erase what they added (error code live-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 code live-unknown). --force-live deploys anyway. A project that has never been deployed goes ahead.
  6. 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.