Skip to content
AstroCraft Docs
On this theme

Publishing

Publishing in Urbic is a git push that runs your build first. There is no deploy API, no webhook to configure and no content database to sync — the CMS commits to your repository, runs the build command you set, and pushes the branch your host is watching.

The authoring side of this is documented at Review and publish. What follows is what Urbic wires it to and what the gates actually check.

The four statuses

An entry moves through draft → review → ready → published, and each status also says whose move is next: yours, a named colleague’s, or nobody’s (already published).

That is content status, not deploy status. An entry can be published — meaning committed to the publish branch — and still not be visible on the site, because the site has not rebuilt yet. Which is what the next section is about.

Where a save stands

The editor’s status pill has four resting states, and the distinction between the last three is the useful part:

Dirty is the browser’s own knowledge — you have typed and not saved.

Awaiting publish means the commit exists but is not yet on the publish branch.

Building means it is on the branch but not provably inside the deployed build.

Live means the deployed site itself reports containing it.

That last check works on any host, with no deploy API, through one line in BaseHead: <meta name="build-rev"> carries the commit the build was built from, computed in astro.config.mjs by git rev-parse HEAD. The CMS fetches the live site, reads that stamp, and asks git whether your save is an ancestor of it.

Ancestry, not equality — and the difference matters. A publisher wants their push confirmed, so the review screen compares by equality. But an author’s save is different: colleagues land commits on top of it as a matter of course, and equality would walk the pill back from “live” every time somebody else saved.

The stamp is treated as untrusted data off a page rather than as a revision. Anything that does not look like a real git rev-parse HEAD output — a truncated tag, an unexpanded CI variable, an attacker-shaped string on a compromised site — reads as “cannot be shown to contain the save”, which is the same resting state as building. A build that cannot compute the stamp (a tarball install rather than a checkout) emits unknown rather than failing the build, and simply cannot confirm liveness.

The two gates

Publish refuses rather than throws, and each refusal names whose next step it is.

The schema gate asks whether the site’s build will parse what this push carries.

The build gate asks the stronger question by actually running your build command in the checkout before anything is pushed. A failing build is an ordinary answer with a next step — read the tail, fix, publish again — not an exception.

The build command is a setting on the Settings screen and runs through sh -c, because real build commands are shell sentences: pnpm i --frozen-lockfile && pnpm build, NODE_ENV=production astro build, pnpm build 2>&1 | tee build.log. A naive argv split cannot express &&, an env prefix or a pipe. It is not an injection surface — the command is the operator’s own input to their own server, written on an admin-gated screen, running as the process that already owns the checkout.

Where the gate’s build writes is controlled by ASTROCRAFT_PUBLISH_OUT_DIR rather than by appending a flag to your command. That is deliberate: a flag stuck on the end of a shell sentence lands correctly on pnpm build and in the wrong place on anything ending in a pipe. Without the redirect, every publish overwrites the dist/ the running process is serving from — harmless under a container that rebuilds on boot, wrong on a plain node ./dist/server/entry.mjs on a box, and wrong in a way nobody notices until a static asset 404s between two publishes.

One honest limitation is recorded in the code: the gate’s timeout kills the direct child, and a grandchild that detached from sh (a build tool forking its own workers) can survive as an orphan. Not data loss — a stray process an operator would see.

The refusals

Publish can come back with a reason instead of a success, and each one belongs to a different person:

Rejected — the push was refused, and somebody at a terminal needs to reconcile. The CMS cannot pull.

Auth — the push had no credentials, which needs an operator with access to the server’s environment.

No remote — the branch does not exist on the remote yet.

Build failed — the gate ran your command and it did not pass.

Licence — see below.

A landed push reloads the review screen rather than patching numbers in the browser. Every number on it is a fact about git and all of them change at once; patching them client-side would be a second model of the server’s own read, and it would be wrong in exactly the way that is hardest to see, because the screen would look plausible.

The licence

A licence key is required only to publish. Everything else in the CMS works without one — sign in, edit, save, commit, preview, invite a team. Publish is the button that runs your build command and pushes the branch, and that is the button activation gates.

Activate at Settings → Licence as an admin: paste the key from your receipt and press Activate. The key itself is never handed back to a browser; only a masked display value is shown.

Tiers are seat-based by number of sites — Solo (1), Studio (10), Agency (25) — and the limit is enforced by the activation count at the vendor rather than by anything in the code, so an install past your tier is refused there.

A confirmed licence is cached for a day, and if the licence service is unreachable, publishing continues for fourteen days from the last confirmation. Only an actual revocation answer stops publishing immediately; a network timeout does not. Full details: Licences and activation.

Without a licence, you can still build and deploy from a terminal exactly as you would any Astro site. The CMS just will not push for you.

Who commits what

Each content commit’s author is the signed-in CMS account, set with git commit --author=…. The committer is the server, which is why the Dockerfile sets GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL — git refuses to commit at all with neither identity nor a configured user.email. Change the placeholder committer email to something real for your studio before deploying.

The remote and branch come from git in src/admin.config.ts: origin and main by default. There is no repository URL in that config, because the checkout already knows its own.

Choosing a branch

Publishing to main on a host that auto-deploys main means an author’s Publish is a production deploy. That is the fast path and it is what the default gives you.

If you would rather a human saw content before it went live, point git.branch at a branch your host does not deploy. Publish then lands commits there, and merging is a normal git operation — the review the CMS gives you is the queue and the build gate; the review your team gives it is a pull request.

A dirty tree

The CMS refuses to sync a dirty tree, which is worth knowing when you deploy: .dockerignore lists only paths git already ignores, precisely because anything tracked that fails to reach the image shows up as a deletion in git status and blocks every content operation while the public site still looks perfectly fine.

NEXT STEPSEO