Skip to content

Build master @ bc50cd5bunreleased — 31 commits past v1.282.0Last reviewed Last changed Grade fresh (aging >90d, stale >180d)Source / History

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 ownsSo no producer carries
the target repo, branch and site rootits 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 deployits 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"]
}
FieldMeaning
schemaAlways site-publish/1.
sectionThe section path under the site root. It must be declared in the site. Lower-case segments, no leading slash, no ...
producerThe command that made the files. It is named in the commit.
source_commitThe producer checkout's HEAD, or null outside a git checkout.
generated_atISO 8601.
filesPaths 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" }
}
KeyMeaning
target.repo, url, branchThe repo that holds the served tree, and the branch it is published from.
target.rootThe directory inside the repo that is the site root. Optional; the default is the repo root.
target.checkoutThe local clone to publish from. Default: $BOB_HOME/cache/site-publish/<name>.
served_urlThe site's origin, with no path. A section is served at <served_url>/<section>/.
hostsFurther hostnames the site answers on. They are probed for every gated section.
sections.<path>.gated + access_applicationThe section is private: it must be behind the named Access application on every host.
sections.<path>.publicThe section is deliberately public.
sections.<path>.producerWho makes it. bob brief uses this to find its section.
deploy.commandargv, run from the checkout root.
deploy.cloudflare_accountA 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:

  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.