learn / 01·8 — under workbook · publishing

put the fileWHERE A URLpoints

You built a workbook. Now you want a link to send. Every habit from modern web development calls that deployment — CI, an account, a build pipeline — and there's even a wb deploy nearby to confuse you. Publish is none of that. Because the artifact is already finished software, publishing is just putting bytes where a URL points: one config file, three verbs, three doors — and one hard rule about what may never touch a public host.

the doorstep12 min read
A small figure on a bright launch apron sliding one glowing finished panel into the single open slot of a monumental signpost tower, whose three arms point off to a static billboard, a git branch flag, and a distant lit engine — a custom-domain banner unfurling above — 1970s sci-fi style

which command makes it a URL?

The parent lesson ended with a complete, self-contained shop.html — the document is the deployment. You can double-click it. So now there's one obvious next move: get it a public link. And here every reflex you trained on twenty years of web development fires at once. A platform account. A CI pipeline. A build step on the host. Environment variables. The whole apparatus of deployment.

Then the vocabulary trips you. Workbooks has a wb deploy too. So which one do you run to get a URL — publish or deploy? This page exists to kill that confusion in the first screen, because the answer reframes the whole task. The reason publishing needs none of that apparatus is that the apparatus was for turning source into a running app on someone else's machine — and you don't have source to build. You have a finished file. The build already happened, in your hand, before any of this.

the DEFINITION

pub·lish /ˈpʌb·lɪʃ/ verb

1. to put an already-assembled workbook on a surface — a website or host. Distribution only. Distinct from deploy, which converges the runtime engine: publish acts on the workbook, deploy acts on the engine.

The split is by object, and the CLI's taxonomy enforces it deliberately, because the two verbs once collided. The old publish apply both rendered org into HTML and shipped it; that was resolved so that bundle renders and assembles, and publish only ships. Publish and deploy were then kept on separate axes, by object: workbook to web is one thing, engine to infrastructure is another, and they never became the same verb.

The two axes are orthogonal — publishing runs along the spine of a workbook's life, while deploy points sideways at the machine that would run it:

flowchart LR
  author["author"] --> build["build"] --> bundle["bundle"] --> run["run"] --> publish["publish"]
  publish --> url(["a URL — the file on a surface"])
  engine{{"the engine"}}
  deploy["deploy"] -. "a different axis — converge the runtime" .-> engine
  run -. "needs an engine?" .-> deploy
  style publish fill:#a8d4f0,stroke:#121316,stroke-width:2.5px
  style url fill:#aee5c2,stroke:#121316
  style deploy fill:#f3c5a3,stroke:#121316
  style engine fill:#f2ddb0,stroke:#121316
  style author fill:#ffffff,stroke:#121316
  style build fill:#ffffff,stroke:#121316
  style bundle fill:#ffffff,stroke:#121316
  style run fill:#ffffff,stroke:#121316
  

Read the spine left to right: author, build, bundle, run, publish — five beats in one workbook's life, ending at a URL. Deploy hangs off to the side on a dotted line, pointed not at the workbook but at the engine: it's about the machine, not the document. You reach for it only when running the thing needs a live runtime at all. Most published workbooks never do.

publish.org — the declared SURFACE

Publishing is declared, not configured by flags. You write one file — publish.org — that names where the workbook should land, and then the CLI converges on that declaration. It's the same declare-then-converge motion the deploy kit uses, so both halves of the system feel like one idea. Here is the scaffold the CLI writes for you, verbatim:

#+TITLE: Workbooks publish config
#+PUBLISH_TARGET: cloudflare-pages
#+PUBLISH_PROJECT: my-workbook
# PUBLISH_TARGET: cloudflare-pages | gh-pages | self-hosted
# PUBLISH_PROJECT: CF Pages project name, GitHub repo (org/name), or runtime URL
# PUBLISH_DOMAIN: optional custom domain (for the printed URL)
#
# `wbx publish apply <workbook.html|.wbundle>` ships the ASSEMBLED workbook —
# run `wb bundle` first. Publish is distribution only.

Now the part that matters about that file: it is inert. It is read by a lenient keyword scanner, not by the OQL kernel that runs your workbook's logic. The reader simply collects every #+KEY: value keyword line and every :KEY: value drawer line into one upper-cased table — and that's all. The runtime's counterpart says the same in its own source: a publish file is parsed with a hand-rolled scan, not the org parser, so it's inert config. Your publish file cannot execute anything. It is a label describing a destination, nothing more.

Three properties carry the whole declaration, plus a couple the runtime lane reads:

propertywhat it meansper target
PUBLISH_TARGETwhich door — one of threecloudflare-pages · gh-pages · self-hosted
PUBLISH_PROJECTthe address at that doorCF project name · org/name repo · runtime base URL
PUBLISH_DOMAINcustom domain — print-time onlychanges the URL the CLI reports, on every target
TITLEa human label for the configcosmetic; the scaffold sets it

The verdict of that table in one line: TARGET picks the door, PROJECT is the address, and DOMAIN only changes what the CLI prints — never where the bytes actually go. Hold on to that last fact; it answers a question people ask later.

init · validate · apply

Three verbs, one motion: declare, check, converge. The exact strings matter here, because they're what you'll actually see in your terminal, and exactness is this page's whole register.

init writes the scaffold. Run it once; if a publish.org already exists it refuses rather than clobber your config — publish.org already exists — and on success it tells you the next move:

$ wbx publish init
wrote publish.org — edit it, then `wbx publish apply <workbook>`

validate is a coherence check with no side effects. It confirms PUBLISH_TARGET is one of the three known doors and that PUBLISH_PROJECT isn't empty — nothing more, nothing shipped. The errors are literal: PUBLISH_TARGET must be cloudflare-pages | gh-pages | self-hosted if the door is unknown, and PUBLISH_PROJECT is required if the address is blank. Clean, it prints what it read back to you:

$ wbx publish validate
ok — publish.org (target=cloudflare-pages project=shop)

apply is the convergence — the only verb that touches a surface. It takes the workbook to ship, defaulting to workbook.html if you don't name one, and ends with a single line: published <workbook> → <url>. If there's no config at all, it stops you early — no publish.org — run `wbx publish init`.

Under apply, the mechanics are humble. The artifact is written as index.html into a throwaway temp directory named wb-publish-<timestamp>, that staging directory is handed to the right door, the URL is printed, and the temp dir is removed. One workbook becomes one index.html becomes one URL. The whole flow, including the ways it stops:

flowchart TD
  init["wbx publish init"] --> edit["edit publish.org
target · project · domain"] edit --> val["wbx publish validate"] val -- "unknown target" --> e1["error: must be
cloudflare-pages | gh-pages | self-hosted"] val -- "empty project" --> e2["error: PUBLISH_PROJECT is required"] val -- "ok" --> apply["wbx publish apply <workbook>"] apply -- "no publish.org" --> e3["error: run `wbx publish init`"] apply -- "staged as index.html
in a temp dir" --> door["the door — wrangler | git | HTTP PUT"] door --> printed(["published <wb> → <url>"]) style init fill:#ffffff,stroke:#121316 style edit fill:#f2ddb0,stroke:#121316 style val fill:#ffffff,stroke:#121316 style apply fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style door fill:#ffffff,stroke:#121316 style printed fill:#aee5c2,stroke:#121316 style e1 fill:#f3c5a3,stroke:#121316 style e2 fill:#f3c5a3,stroke:#121316 style e3 fill:#f3c5a3,stroke:#121316

Walk that graph as a story: init lays down the file; you edit it; validate is the fork where two failures can send you back — an unknown target, an empty project — and the clean path leads to apply. Apply has its own early exit if no config exists; otherwise the staged index.html goes to whichever door you named, and the URL is printed. Every failure edge is a copy-paste of the real error string. Nothing here guesses.

why .org is REFUSED

Here's the error a reader will actually hit, and it's worth hitting on purpose, because it's where the whole lesson lands. Hand publish a raw .org file and it refuses, flatly:

$ wbx publish apply shop.org
error: publish ships ASSEMBLED workbooks — run `wb bundle shop.org` first

This is the resolved collision made visible. Publish does not render. That job belongs to bundle, which lints and assembles your org source into a runnable workbook.html. Publish is distribution only — it ships what's already finished. So the fix is one step earlier in the spine:

$ wbx bundle shop.org && wbx publish apply shop.wbundle
published shop.wbundle → https://shop.pages.dev

Notice publish happily took a .wbundle. It opens it as the plain zip it is, pulls workbook.html out by name, and ships that — and if the bundle somehow lacks one, it says so: bundle has no workbook.html. A bare .html ships as-is. The full input rule is three rows wide:

you hand itwhat happens
workbook.htmlships as-is — it's already assembled
shop.wbundleunzipped in memory; the inner workbook.html ships
shop.orgrefused — run `wb bundle` first

The verdict of those three rows is the whole thesis: assembly happens before the doorstep, never on it. The .html and the .wbundle are finished; the .org isn't, so it's turned away. Publish is distribution, bundle is assembly — and the guard exists so the two can never quietly merge back into one verb again.

three doors, one MOTION

This is the centerpiece. Three targets, the same declare-then-converge motion behind all of them, and they're dumb on purpose — because the file is smart. A static host, a git branch, and a raw HTTP PUT can all accept the same workbook because the workbook needs nothing from them but a place to sit. Watch the same apply fan out:

sequenceDiagram
  participant U as wbx publish apply
  participant S as temp stage (index.html)
  participant D as the door
  U->>S: write artifact as index.html
  alt cloudflare-pages
    S->>D: wrangler pages deploy --project-name shop
    D-->>U: https://shop.pages.dev
  else gh-pages
    S->>D: git init -b gh-pages · commit · push -f
    D-->>U: https://acme.github.io/shop/
  else self-hosted
    S->>D: PUT {base}/w/shop  (bearer from PUBLISH_TOKEN)
    D-->>U: https://nexus.example.com/w/shop
  end

Read it as a branching story. The CLI always does the same first move: stage the workbook as index.html. Then, depending on the target, one of three doors opens — and each prints back a different shape of URL. Each lane, in detail:

cloudflare-pages. The CLI shells out to wrangler: wrangler pages deploy <stage> --project-name <project> --commit-dirty=true, where <stage> is that temp dir holding your file as index.html. The printed URL is https://<project>.pages.dev — unless you set PUBLISH_DOMAIN, in which case it prints https://<domain> instead.

gh-pages. Your PUBLISH_PROJECT can be an org/name shorthand (expanded to a GitHub HTTPS URL), a full URL, or an SSH remote. The mechanics are blunt and worth knowing before you lean on them: the CLI makes a fresh git repo in the stage dir on a gh-pages branch, stages everything, commits as wb <[email protected]> with the message wbx publish, and then force-pushes that single-commit branch. The URL is https://<org>.github.io/<repo>/, or your PUBLISH_DOMAIN if set. Read the force-push honestly: the gh-pages branch is treated as an artifact surface, not a place to keep history. Each publish overwrites it.

self-hosted. Your PUBLISH_PROJECT is a runtime base URL, and the CLI issues a single PUT <base>/w/<slug> — where the slug is the workbook's filename stem — carrying the artifact bytes, with an optional bearer token read from the PUBLISH_TOKEN environment variable. The printed URL is <base>/w/<slug>: the same path the engine serves it at. This is the door that's not dumb — it's an engine — and it gets its own section next.

publishing INTO your engine

depth rung · skippable — the lane where publish touches the engine

The first two doors are storage: a static host, a git branch. They hold bytes and hand them back. The self-hosted door is different in kind — the surface is a Nexus, a live engine — and this is the one case where the words "publish" and "the engine" touch without becoming the same verb. Publish still only ships a finished artifact; it just ships it to something that can do more than serve it flat.

sequenceDiagram
  participant C as wbx (PUBLISH_TOKEN in env)
  participant E as your Nexus
  C->>E: PUT /w/shop  · artifact bytes · Bearer
  E-->>C: 201 stored
  Note over C: prints {base}/w/shop
  participant V as a visitor
  V->>E: GET /w/shop
  E-->>V: serves the workbook — gated per request

The story: with your token in the environment, the CLI PUTs the artifact to /w/shop; the engine stores it and answers 201 stored; the CLI prints the URL. Later a visitor does a GET on the same path and the engine serves the workbook back — and because it's a live engine answering each request, it can gate that request, hold the auth, run live state. Safe phrasing for what you can rely on today: you PUT the artifact to the engine's /w/<slug> and it serves it at the same path. The bearer you present in PUBLISH_TOKEN is simply the token your engine accepts — if you stood that engine up yourself, it's the bearer your deploy minted for it; the tokens material covers where that comes from.

That's the whole reason this lane exists. A static-hosted workbook is exactly as alive as the file — which is to say, not. If you want per-request gating, schedules, agents, or multiplayer, you don't publish onto a dumb surface; you publish into an engine. Same three verbs, same finished artifact — a smarter doorstep.

what may never go on a static HOST

This is the question that actually keeps people up: is my data safe on a public host? The honest answer is a flat no — unless the workbook's posture is public. A static host ships the whole file to anyone who asks. Any gating drawn on top of inlined data is decoration, not security.

Every workbook route carries one of three access postures, and only one of them is safe to bake into a public static file:

posturecloudflare-pages / gh-pagesself-hosted
publicallowed — nothing to leakallowed
gated_datarefused — would ship the data to everyoneallowed — engine gates per request
gated_routerefused — client-side gating is theaterallowed — engine gates per request

The verdict of that grid: a gated workbook may only go through the self-hosted door, where a live engine can refuse each request. The runtime's own validator enforces this, and says it in words: a gated workbook cannot publish to cloudflare-pages or gh-pages, because a public static host ships the whole workbook to anyone — client-side gating is theater. Use self-hosted, where the runtime gates per request, or a local desktop shell. The check that decides this is named exactly for the job — it asks whether a workbook of a given posture can be safely baked into a public static artifact, and answers yes only for public.

There's a smaller hygiene guard too. Validate warns if a property key looks like a secret — anything ending in TOKEN, KEY, SECRET, ACCOUNT, PASSWORD, or CREDENTIAL — or if a value looks like a long hex ID, telling you to use an environment variable instead of storing it in publish.org. That's why the self-hosted bearer comes from PUBLISH_TOKEN in the environment and never from the config file. The file is inert and public-adjacent; secrets don't belong in it.

One honest seam: today this posture-and-secret enforcement lives on the runtime side. The Rust wbx lane validates only the target and project — it does not yet run the posture or secret checks itself. So the anti-leak guarantee is real, but it's the engine that holds it; treat a gated workbook as self-hosted-only regardless of which lane you reach for.

where publish ENDS

Honesty section. Five edges worth knowing before you lean on them.

gh-pages is a force-push. Each publish writes a fresh single-commit branch and force-pushes it, destroying whatever history was on gh-pages. That's fine for an artifact surface — the branch is a place the rendered file lives, not a log of how it got there — but don't keep anything on that branch you'd miss.

One file, one URL. Publish maps one workbook to one index.html to one URL. Multi-page sites are a different mode entirely — a runtime-side site.org manifest that renders a list of pages with a shared sidebar into a flat directory. That's a separate tool, not a flag on publish.

A published static workbook is inert. It is exactly as alive as the file. No schedules, no agents, no live state — that's what docking into an engine is for. If your workbook needs to keep doing things with nobody watching, the self-hosted door is the one you want, not a static host.

PUBLISH_DOMAIN doesn't configure DNS. It changes only the URL the CLI prints, on every target. The actual custom-domain wiring happens at the host — the Cloudflare Pages dashboard, a GitHub Pages CNAME. Setting it makes the printed link match where your DNS already points; it does not point your DNS.

Two publish surfaces exist, and ships-only is the canon. The Rust wbx lane ships only — it's the resolved design. An older Elixir wb publish still renders org into HTML before shipping, and adds a fourth target — desktop-app — that scaffolds a Tauri app. That render step predates the collision fix and is exactly the behavior the taxonomy resolved away. When these pages say publish ships and bundle renders, they mean the canon: wbx.

questions people actually ASK

Do viewers need anything installed?

No. A published workbook is a static HTML file. Anyone with a browser opens the URL and the workbook runs — the runtime it needs is bound inside the file. There's no server process to provision, no account to make, nothing on the host that builds.

Can I publish straight from workbook.org?

No — bundle first. Hand publish a .org and it refuses with publish ships ASSEMBLED workbooks — run `wb bundle` first. Publish is distribution; rendering org into a runnable file is bundle's job. Run wbx bundle, then apply the .wbundle or the .html.

How do I update a published workbook?

Re-run wbx publish apply. It's idempotent convergence to the same declaration: same target, same project, same URL. On Cloudflare Pages the new deploy supersedes the old; on gh-pages the branch is force-pushed afresh; on a Nexus the PUT overwrites /w/<slug>. You don't tear down and recreate — you re-converge.

Where do I set up my custom domain?

At the host — the Cloudflare Pages dashboard or a GitHub Pages CNAME. PUBLISH_DOMAIN only changes the URL the CLI prints back to you; it does no DNS wiring. Set it so the link you're handed matches where your domain already points.

Can I password-protect a published workbook?

Not on a static host — and that's not a missing feature, it's the truth about static hosting. The file is the whole workbook; anyone who can fetch the URL has every byte. To actually gate, the workbook's posture must be gated and it must go through the self-hosted door, where a live engine refuses each unauthorized request.

Publish vs deploy, one more time?

Publish puts a finished workbook on a surface. Deploy converges an engine on infrastructure. Different objects, different axes — you publish a document, you deploy a machine.

keep GOING

Publishing sits at the end of one workbook's life — next to the thing it ships, the precondition it depends on, and the engine that makes the live door possible.