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
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:
| property | what it means | per target |
|---|---|---|
PUBLISH_TARGET | which door — one of three | cloudflare-pages · gh-pages · self-hosted |
PUBLISH_PROJECT | the address at that door | CF project name · org/name repo · runtime base URL |
PUBLISH_DOMAIN | custom domain — print-time only | changes the URL the CLI reports, on every target |
TITLE | a human label for the config | cosmetic; 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 it | what happens |
|---|---|
workbook.html | ships as-is — it's already assembled |
shop.wbundle | unzipped in memory; the inner workbook.html ships |
shop.org | refused — 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:
| posture | cloudflare-pages / gh-pages | self-hosted |
|---|---|---|
public | allowed — nothing to leak | allowed |
gated_data | refused — would ship the data to everyone | allowed — engine gates per request |
gated_route | refused — client-side gating is theater | allowed — 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.