From 85261160232d80e6f4e7f631e043b103fc0473f1 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:13:44 +0300 Subject: [PATCH 1/2] feat(cli): cloudhal build --published and the static not-published page (RFC-0043 PR 2) `cloudhal build --published` builds the published snapshot only. A site that was never published gets the not-published status page as its whole output, written without an Astro run: index.html and 404.html from @cloudhal/astro-renderer/status-page in the site's default locale, a robots.txt that disallows everything, a _headers file (no-store, noindex, the page's CSP) and a .cloudhal-status marker. A published pointer to a missing snapshot is an error, never "not published". Plain `cloudhal build` is unchanged (published, then latest, then a fresh snapshot). The Docker image's site-build passes --published, so a fresh self-host no longer serves drafts before its first publish. The Caddyfile answers every path except robots.txt with the page, 503, Retry-After: 3600 and no-store while the marker exists. The self-host guide documents the headers and drops the latest-snapshot caveat. Tests: build-published.test.ts covers the status output (locale, a link at dist removed rather than followed, the English fallback), a spawned build before and after a publish, and --snapshot being refused. The docker smoke test now builds before the first publish and runs the compose Caddyfile in a caddy:2.11 container on the same volume: 503 with Retry-After before the publish, 200 after it, then the swap and the kept-previous checks on a third build. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/build-published-status.md | 5 + deploy/docker/Caddyfile | 27 +++- deploy/docker/README.md | 2 +- deploy/docker/bin/site-build | 19 ++- docs/guides/self-host.md | 20 ++- packages/cli/package.json | 1 + packages/cli/src/cli.ts | 2 + packages/cli/src/cli/commands-local.ts | 11 ++ packages/cli/src/cli/help.ts | 6 +- packages/cli/src/commands/astro.ts | 52 ++++++- packages/cli/src/commands/build-status.ts | 84 +++++++++++ packages/cli/src/commands/snapshot.ts | 25 +++- packages/cli/test/build-published.test.ts | 163 ++++++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/docker-smoke.ts | 163 +++++++++++++++++++--- 15 files changed, 544 insertions(+), 39 deletions(-) create mode 100644 .changeset/build-published-status.md create mode 100644 packages/cli/src/commands/build-status.ts create mode 100644 packages/cli/test/build-published.test.ts diff --git a/.changeset/build-published-status.md b/.changeset/build-published-status.md new file mode 100644 index 00000000..a5907238 --- /dev/null +++ b/.changeset/build-published-status.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/cli": patch +--- + +`cloudhal build --published` (RFC-0043 §1.10) builds the published snapshot only, never the latest snapshot or the drafts. When nothing is published yet, it writes the `not-published` status page as the whole output instead, in the site's default language: `index.html` and `404.html`, a `robots.txt` that disallows everything, a `_headers` file with `no-store` and `noindex`, and a `.cloudhal-status` marker. The Docker image's `site-build` now passes the flag, and the compose file's Caddyfile answers every path except `robots.txt` with that page, status 503 and `Retry-After: 3600` while the marker exists. So a fresh self-hosted site no longer serves drafts before its first publish. `cloudhal build` without the flag is unchanged. diff --git a/deploy/docker/Caddyfile b/deploy/docker/Caddyfile index 63cf0d0a..23dfca43 100644 --- a/deploy/docker/Caddyfile +++ b/deploy/docker/Caddyfile @@ -2,7 +2,8 @@ # point at this machine, ports 80 and 443 open). Studio and the API at CLOUDHAL_BASE_URL, proxied # with X-Forwarded-Proto/Host/For (set CLOUDHAL_BEHIND_PROXY=1); the public site at # CLOUDHAL_SITE_URL, from /data/dist: the built site as `site-build` publishes it (a real folder -# with the 0.6.3 image; a symlink to /data/site/current, swapped atomically, from 0.6.4 on). +# with the 0.6.3 image; a symlink to /data/site/current, swapped atomically, from 0.6.4 on). Before +# the first publish that build is the not-published status page, answered with 503 (0.7.2 on). {$CLOUDHAL_BASE_URL} { reverse_proxy cloudhal:8787 } @@ -19,6 +20,30 @@ file } header @immutable Cache-Control "public, max-age=31536000, immutable" + # Before the first publish `site-build` (`cloudhal build --published`, RFC-0043 §1.10) writes + # the not-published status page instead of the site, with a `.cloudhal-status` marker. While + # the marker exists every path but robots.txt (which must not be a 503, and disallows all) is + # that page with 503, Retry-After and no-store: crawlers come back later and nothing is cached + # or indexed. These are the headers the Cloudflare deployment sends with the same page. + @status { + file /.cloudhal-status + not path /robots.txt + } + handle @status { + header { + Cache-Control "no-store" + Retry-After "3600" + X-Robots-Tag "noindex" + X-Cloudhal-State "not-published" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + } + rewrite * /index.html + file_server { + status 503 + } + } # A page opened before the last build may still ask for that build's hashed assets: they come # from /data/site/previous, the build before the current one (0.6.4 and later; immutable too). @previous { diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 0f485f1c..83f7db71 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -3,7 +3,7 @@ The official self-host image, `ghcr.io/cloudhal-dev/cloudhal`, built from each npm release (issue #143). The guide is [docs/guides/self-host.md](../../docs/guides/self-host.md); this folder holds what it refers to: - `Dockerfile` — `node:24-bookworm-slim`, `@cloudhal/cli` installed with npm (`CLOUDHAL_VERSION`), the blog starter's dependencies next to it, non-root, `/data` volume, port 8787, a health check on `/api/health`. `install.mjs` is its build stage. -- `bin/entrypoint` — `cloudhal studio --init-if-empty` in `/data`; `bin/site-build` — the public site as static HTML, built in a staging copy of the project and published at `/data/dist`, a symlink to the newest build under `/data/site` (swapped atomically, never a 404 — except for a moment on the first build after upgrading from 0.6.3, when the real folder moves aside to `/data/site/legacy-0.6.3`; the build before the current one is kept as `/data/site/previous`). Never run an Astro build (`cloudhal build`, `cloudhal deploy`) inside `/data` by hand: it empties the served build through the symlink. +- `bin/entrypoint` — `cloudhal studio --init-if-empty` in `/data`; `bin/site-build` — the public site as static HTML of the published snapshot (`cloudhal build --published`; before the first publish the not-published status page with a `.cloudhal-status` marker, which the `Caddyfile` answers with 503), built in a staging copy of the project and published at `/data/dist`, a symlink to the newest build under `/data/site` (swapped atomically, never a 404 — except for a moment on the first build after upgrading from 0.6.3, when the real folder moves aside to `/data/site/legacy-0.6.3`; the build before the current one is kept as `/data/site/previous`). Never run an Astro build (`cloudhal build`, `cloudhal deploy`) inside `/data` by hand: it empties the served build through the symlink. - `compose.yml`, `.env.example`, `Caddyfile` — Studio alone, or with Caddy (profile `caddy`) serving Studio and the built site with automatic TLS. Build it yourself from a release, or from this checkout's packages (`edge`): diff --git a/deploy/docker/bin/site-build b/deploy/docker/bin/site-build index fe6d91b6..1fd1edb2 100755 --- a/deploy/docker/bin/site-build +++ b/deploy/docker/bin/site-build @@ -18,10 +18,13 @@ # 0.6.3 folder is kept as `site/legacy-0.6.3` for one build. Never run an Astro build in /data by # hand (`cloudhal build`, `cloudhal deploy`): it would empty the served build through the symlink. # -# The snapshot is the published one, else the latest: before the first publish the site is built -# from the latest snapshot (RFC-0043's `cloudhal build --published` is not in this version). The -# project's dependencies are the image's node_modules, linked into the project on first use, so no -# install and no network is needed. +# The snapshot is the published one only (`cloudhal build --published`, RFC-0043 §1.10): drafts and +# unpublished snapshots never reach the public site. Before the first publish the build is the +# not-published status page with a `.cloudhal-status` marker, which the compose file's Caddyfile +# answers with 503 and Retry-After; the first build after a publish replaces it like any other. +# Arguments go to `cloudhal build` after `--published` (so `--snapshot` is refused). The project's +# dependencies are the image's node_modules, linked into the project on first use, so no install +# and no network is needed. set -eu cd /data if [ ! -f cloudhal.json ]; then @@ -121,7 +124,7 @@ for entry in .[!.]* ..?* *; do *) cp -a "$entry" "$stage/" ;; esac done -(cd "$stage" && cloudhal build "$@") +(cd "$stage" && cloudhal build --published "$@") # Publish: the finished build → /data/site/, then each pointer with one rename. id="$(date -u +%Y%m%dT%H%M%SZ).$$" @@ -146,4 +149,8 @@ ln -sfn site/current "dist.$$" mv -T "dist.$$" dist # Builds that neither `current` nor `previous` points at. find site -mindepth 1 -maxdepth 1 -type d ! -name "$id" ! -name "${prev:-$id}" -exec rm -rf {} + -echo "site-build: /data/dist -> site/current -> $id" +if [ -f "site/$id/.cloudhal-status" ]; then + echo "site-build: /data/dist -> site/current -> $id: the $(cat "site/$id/.cloudhal-status") status page (nothing is published yet)" +else + echo "site-build: /data/dist -> site/current -> $id" +fi diff --git a/docs/guides/self-host.md b/docs/guides/self-host.md index 8dcc9bd2..f9a86b3e 100644 --- a/docs/guides/self-host.md +++ b/docs/guides/self-host.md @@ -97,7 +97,7 @@ A one-off `docker compose --env-file … up -d` is not enough: the next command ## The public site -`cloudhal studio` serves Studio and the API, not the site. The site is `cloudhal build` output: static HTML of the published snapshot — or of the latest snapshot before the first publish (a build that renders a status page instead until then is RFC-0043's `cloudhal build --published`, not in this version). The image builds and publishes it with +`cloudhal studio` serves Studio and the API, not the site. The site is `cloudhal build --published` output: static HTML of the published snapshot, never of drafts or of a snapshot nobody published. Before the first publish it is a status page instead, answered with 503 (*before the first publish*, below). The image builds and publishes it with ```bash docker compose exec cloudhal site-build @@ -105,6 +105,20 @@ docker compose exec cloudhal site-build and Caddy (or your web server) serves `/data/dist` from the volume. `cloudhal build` always writes the project's `dist` folder and empties it first, so `site-build` never runs it in `/data`: it copies the project's files to `/data/.site-build` (its `node_modules` and data folder linked, so the build reads the same database) and builds there. The finished build moves to `/data/site/`; then `/data/site/current`, `/data/site/previous` and `/data/dist` — symlinks to the newest build, the one before it, and `current` — are each replaced with one rename (`ln -sfn` and `mv -T`, which is atomic). Visitors see the previous build until the new one is complete, never a 404 or a half-written page — except for a moment on the first build after upgrading from 0.6.3, when the real `/data/dist` folder moves to `/data/site/legacy-0.6.3` (kept as `previous` for one build) and the symlink takes its place: the container's coreutils cannot swap a folder for a symlink in one step. A failed build changes nothing. The build before the current one is kept, and the compose file's Caddyfile serves a hashed asset (`/_astro/*`, `/_cloudhal/*`, `/media/*`) from it when the current build no longer has it, so a page opened before the swap still loads its scripts and media; older builds are removed. Never run an Astro build inside `/data` by hand (`cloudhal build`, `cloudhal deploy`): it would write through the `dist` symlink and empty the served build. Run `site-build` after each publish; a cron line on the host (`*/15 * * * * cd /path/to/cloudhal && docker compose exec -T cloudhal site-build`) keeps the site at most that far behind (`exec` runs in the running container, so it needs no `COMPOSE_ENV_FILES`). Drafts and scheduled releases show in Studio at once (the editor previews drafts); the public pages change when the next build runs. Signed preview links to unpublished content on the public site belong to the Cloudflare deployment; the container has no preview secret. Rebuilding on publish from inside the container is a follow-up issue; serving the site live from the snapshot, as the Cloudflare deployment does, is an RFC of its own. +**Before the first publish.** With nothing published, `cloudhal build --published` writes RFC-0043's `not-published` status page as the whole build: `index.html` and `404.html` are the page ("Coming soon" under the site's name, in the site's default language), `robots.txt` disallows every path, `_headers` sets `Cache-Control: no-store`, `X-Robots-Tag: noindex` and the page's own `Content-Security-Policy` for hosts that read it, and a marker file `.cloudhal-status` holds the state (`not-published`). `site-build` prints that it published the status page. The compose file's Caddyfile answers every path but `/robots.txt` with that page while the marker exists, with the headers the Cloudflare deployment sends for the same state: + +| Header | Value | +| --- | --- | +| status | `503 Service Unavailable` | +| `Retry-After` | `3600` | +| `Cache-Control` | `no-store` | +| `X-Robots-Tag` | `noindex` | +| `X-Cloudhal-State` | `not-published` | +| `Content-Security-Policy` | `default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'` | +| `X-Content-Type-Options`, `Referrer-Policy` | `nosniff`, `no-referrer` | + +A 503 with `Retry-After` tells crawlers to come back rather than index an empty site, and `no-store` keeps the page out of every cache, so the first build after the first publish replaces it at once. With a web server of your own, do the same: while `/data/dist/.cloudhal-status` exists, answer every path except `/robots.txt` with `/data/dist/index.html`, status 503 and these headers (an older Caddyfile serves the page with 200, which is harmless but tells crawlers nothing). A theme's own `statusPage` hook is not used by the static build: the page is always the built-in one. `cloudhal build` without `--published`, as in CI or on your own machine, is unchanged: it renders the published snapshot, else the latest one, else a fresh snapshot of the drafts. + **The site's origin.** Canonical links, the sitemap, feeds and structured data need the public origin, which a static build cannot learn from a request. `cloudhal build` takes it from the site's primary domain, kept in `cloudhal.json` as `domains.site` and set with `cloudhal domain set ` (`docker compose exec cloudhal cloudhal domain set www.example.com`). `site-build` does this for you from `CLOUDHAL_SITE_URL`, an `https://` origin without a path or port, whenever it is set and differs (a name without a dot, such as `localhost`, or an IP address is skipped with a warning, and the build goes on). The domain then becomes the site's primary domain on the live site too, replacing one chosen in Studio. A published snapshot that already names a primary domain keeps it in that build: the new one reaches the pages with the next publish, and `site-build` says so. Without any domain the build's URLs say `localhost`. **Headers and the 404 page.** The build writes a `_headers` file — the security headers the Cloudflare deployment sends, and a cache rule for `/media/*` when the site has media — for hosts that read it, such as Cloudflare. Caddy does not: the compose file's Caddyfile marks `/media/*`, `/_astro/*` and `/_cloudhal/*` immutable on its own (files named by content hash, and only when the file exists, so a miss is never cached), and the security headers can be copied from `/data/dist/_headers` into it. A miss is answered with `/404.html`, which exists only when the site has a page at `/404` (the starter ships none); until then it is the web server's plain 404. @@ -127,7 +141,7 @@ Database migrations run when the container starts, as they do on every `cloudhal 2. **Fix two values a 0.6.3 `.env` carries**, before anything starts: - `CLOUDHAL_BASE_URL=http://localhost:8787`: behind a proxy, set the public origin. The new entrypoint refuses `CLOUDHAL_BEHIND_PROXY=1` with a localhost base URL, and the container would restart in a loop. - `CLOUDHAL_SITE_URL=https://www.example.com`: set the site's origin, or leave it empty (`site-build` refuses to make an example domain the site's). -3. **Download the new files** next to `.env`: `compose.yml`, the `Caddyfile` for the caddy profile (an old one keeps working, but the previous-build fallback for hashed assets needs the new one; if `./Caddyfile/` is a folder, `rmdir Caddyfile` first), and `.env.example` to compare with your `.env` (saved as `.env.example`, never over `.env`): +3. **Download the new files** next to `.env`: `compose.yml`, the `Caddyfile` for the caddy profile (an old one keeps working, but the previous-build fallback for hashed assets and the 503 before the first publish ([the public site](#the-public-site)) need the new one; if `./Caddyfile/` is a folder, `rmdir Caddyfile` first), and `.env.example` to compare with your `.env` (saved as `.env.example`, never over `.env`): ```bash curl -fsSLO https://raw.githubusercontent.com/cloudhal-dev/cloudhal/main/deploy/docker/compose.yml @@ -229,4 +243,4 @@ docker compose cp cloudhal:/data/backup.cloudhal . docker build --build-arg CLOUDHAL_VERSION=0.6.4 -t cloudhal:local deploy/docker ``` -builds a release version from npm (0.6.4 or later: the smoke test expects the setup token); `node scripts/pack-packages.ts deploy/docker/tarballs` followed by the same build without the argument builds `edge` from a checkout. `node scripts/docker-smoke.ts --image cloudhal:local` runs what CI runs: an empty volume, the health check, Studio's page, the first owner through the setup token (refused without it), Studio's page, an anonymous `/mcp` call, a publish and `site-build` twice (the second build swaps the served tree). `--platform linux/arm64` runs the other architecture's image where Docker can (Apple silicon, or QEMU). +builds a release version from npm (0.6.4 or later: the smoke test expects the setup token); `node scripts/pack-packages.ts deploy/docker/tarballs` followed by the same build without the argument builds `edge` from a checkout. `node scripts/docker-smoke.ts --image cloudhal:local` runs what CI runs: an empty volume, the health check, Studio's page, the first owner through the setup token (refused without it), an anonymous `/mcp` call, `site-build` before the first publish (the status page, which the compose file's Caddyfile, run in a `caddy:2.11` container on the same volume, answers with 503 and `Retry-After`), a publish and `site-build` twice more (the site, served with 200; the third build swaps the served tree). `--platform linux/arm64` runs the other architecture's image where Docker can (Apple silicon, or QEMU). diff --git a/packages/cli/package.json b/packages/cli/package.json index e4f28fa7..456d6b25 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,6 +32,7 @@ "@cloudhal/agent-runtime": "workspace:*", "@cloudhal/analytics": "workspace:*", "@cloudhal/api": "workspace:*", + "@cloudhal/astro-renderer": "workspace:*", "@cloudhal/cloudflare": "workspace:*", "@cloudhal/comments": "workspace:*", "@cloudhal/core-contracts": "workspace:*", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cdac732b..6b82eecb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -20,6 +20,7 @@ export const OPTIONS = { label: { type: "string" }, publish: { type: "boolean" }, snapshot: { type: "string" }, + published: { type: "boolean" }, "no-auth": { type: "boolean" }, scopes: { type: "string" }, skills: { type: "string" }, @@ -90,6 +91,7 @@ const ONLY_FOR: Readonly< "base-url": { commands: ["studio"] }, "behind-proxy": { commands: ["studio"] }, "init-if-empty": { commands: ["studio"] }, + published: { commands: ["build"] }, }; /** diff --git a/packages/cli/src/cli/commands-local.ts b/packages/cli/src/cli/commands-local.ts index a350f995..b37c793d 100644 --- a/packages/cli/src/cli/commands-local.ts +++ b/packages/cli/src/cli/commands-local.ts @@ -1,5 +1,6 @@ import { isAgentMode, type Scope } from "@cloudhal/agent-runtime"; import { build, dev } from "../commands/astro.ts"; +import { STATUS_MARKER, staticRetryAfter } from "../commands/build-status.ts"; import { chromeGet, chromeSet } from "../commands/chrome.ts"; import { init } from "../commands/init.ts"; import { mcp } from "../commands/mcp.ts"; @@ -57,6 +58,16 @@ const snapshotCommand: CliCommand = async (_rest, values, { projectDir }) => { }; const buildCommand: CliCommand = async (_rest, values, { projectDir }) => { + if (values.published) { + if (values.snapshot) throw new Error("usage: cloudhal build [--snapshot | --published]"); + const r = await build({ projectDir, published: true }); + if (r.state === "published") console.log(`built ${r.outDir} from ${r.snapshotId} (published)`); + else + console.log( + `${r.state}: nothing is published yet; wrote the status page to ${r.outDir} (${r.files.join(", ")}); a web server should answer it with 503 and Retry-After: ${staticRetryAfter(r.state)} while ${STATUS_MARKER} exists`, + ); + return 0; + } const r = await build({ projectDir, ...(values.snapshot ? { snapshotId: values.snapshot } : {}), diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts index 235967e6..df1a5512 100644 --- a/packages/cli/src/cli/help.ts +++ b/packages/cli/src/cli/help.ts @@ -8,7 +8,11 @@ const PROJECT = ` cloudhal init [--name ] [--starter blog] create cloudhal dev [--port ] [--host []] live preview (drafts visible); --host alone is 0.0.0.0, every IPv4 address (:: for IPv6 too) cloudhal snapshot [--label ] [--publish] freeze the current graph (optionally publish) - cloudhal build [--snapshot ] static HTML from the published/latest snapshot + cloudhal build [--snapshot | --published] static HTML from the published snapshot, else the + latest, else a fresh one of the drafts; --published: + the published snapshot only, and before the first + publish the not-published status page with a + .cloudhal-status marker (RFC-0043; self-hosting) cloudhal studio [--port ] [--host []] [--base-url ] [--behind-proxy] [--no-auth] [--init-if-empty [--starter blog] [--name ]] API + Studio UI for this project (default :8787); diff --git a/packages/cli/src/commands/astro.ts b/packages/cli/src/commands/astro.ts index 33a2fd21..10aa1b89 100644 --- a/packages/cli/src/commands/astro.ts +++ b/packages/cli/src/commands/astro.ts @@ -1,8 +1,9 @@ import path from "node:path"; import { openLocalStore } from "@cloudhal/core-ts/local"; import { readProjectConfig } from "../config.ts"; +import { type StaticState, writeStatusBuild } from "./build-status.ts"; import { startLocalScheduler } from "./scheduler-local.ts"; -import { resolveBuildSnapshot } from "./snapshot.ts"; +import { resolveBuildSnapshot, resolvePublishedBuild } from "./snapshot.ts"; export interface DevOptions { readonly projectDir: string; @@ -69,17 +70,58 @@ export interface BuildOptions { readonly projectDir: string; /** Render this snapshot instead of the published/latest one. */ readonly snapshotId?: string; + /** + * RFC-0043 §1.10: render the published snapshot only, never the latest one or the drafts; a + * site that was never published gets the `not-published` status page as its whole output + * (`build-status.ts`). Cannot be combined with `snapshotId`. + */ + readonly published?: boolean; + /** The clock for the status page (RFC-0008 §9.4); `Date.now` by default. */ + readonly now?: () => number; } -/** Static build from a snapshot: published → latest → freshly created. */ +/** `build({ published: true })`: the published site, or the status page that stands for it. */ +export type PublishedBuildResult = + | { readonly state: "published"; readonly snapshotId: string; readonly outDir: string } + | { readonly state: StaticState; readonly outDir: string; readonly files: readonly string[] }; + +/** + * Static build from a snapshot: published → latest → freshly created. With `published`, the + * published snapshot or the status page (RFC-0043 §1.10). + */ +export async function build( + options: BuildOptions & { readonly published: true }, +): Promise; +export async function build(options: BuildOptions): Promise<{ snapshotId: string; outDir: string }>; export async function build( options: BuildOptions, +): Promise<{ snapshotId: string; outDir: string } | PublishedBuildResult> { + if (!options.published) { + const snapshotId = options.snapshotId ?? (await resolveBuildSnapshot(options.projectDir)).id; + return astroBuild(options.projectDir, snapshotId); + } + if (options.snapshotId) + throw new Error("--published builds the published snapshot: drop --snapshot"); + const target = await resolvePublishedBuild(options.projectDir); + if ("snapshot" in target) { + const r = await astroBuild(options.projectDir, target.snapshot.id); + return { state: "published", ...r }; + } + const outDir = path.join(path.resolve(options.projectDir), "dist"); + const state = "not-published" as const; + const now = (options.now ?? Date.now)(); + const files = await writeStatusBuild({ outDir, state, site: target.unpublished, now }); + return { state, outDir, files }; +} + +async function astroBuild( + projectDir: string, + snapshotId: string, ): Promise<{ snapshotId: string; outDir: string }> { - const snapshotId = options.snapshotId ?? (await resolveBuildSnapshot(options.projectDir)).id; process.env.CLOUDHAL_SOURCE = `snapshot:${snapshotId}`; - const cfg = await readProjectConfig(options.projectDir); + const cfg = await readProjectConfig(projectDir); const astro = await import("astro"); - const root = path.resolve(options.projectDir); + const root = path.resolve(projectDir); // A static build has no request: without `site`, Astro's URLs (canonical links, the sitemap, // feeds) say localhost. The site's own primary domain still wins (`siteOrigin`). const site = cfg.domains?.site ? `https://${cfg.domains.site}` : undefined; diff --git a/packages/cli/src/commands/build-status.ts b/packages/cli/src/commands/build-status.ts new file mode 100644 index 00000000..c86f0e04 --- /dev/null +++ b/packages/cli/src/commands/build-status.ts @@ -0,0 +1,84 @@ +/** + * The static output of `cloudhal build --published` for a site that cannot show its pages + * (RFC-0043 §1.10): the status page as the whole build, written without an Astro run. + * + * - `index.html` and `404.html` are the built-in `not-published` document (`statusPageHtml`, in + * the site's default locale), so every route a static host falls back to shows it; + * - `robots.txt` disallows everything; + * - `_headers` sets the page's headers on `/*` for hosts that read it (Cloudflare static assets, + * Netlify): `no-store`, `noindex` and the status page's own CSP. Those hosts cannot send a 503; + * - `.cloudhal-status` holds the state. A web server that can set a status turns it into one: the + * Docker image's Caddyfile answers every path but `robots.txt` with `index.html`, `503`, + * `Retry-After` and `no-store` while the marker exists (docs/guides/self-host.md). + * + * A theme's `statusPage` hook (§1.7) is not run here: it is theme code, which only an Astro build + * loads, and the built-in page is what the hook falls back to anyway. + */ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + retryAfter, + type SiteState, + STATUS_CSP, + statusPageHtml, +} from "@cloudhal/astro-renderer/status-page"; +import type { Site } from "@cloudhal/core-contracts"; + +/** The marker file a status build leaves at the root of its output; it holds the state. */ +export const STATUS_MARKER = ".cloudhal-status"; + +/** The states a static build writes: `not-published` now, `maintenance` from RFC-0043 PR 3. */ +export type StaticState = Extract; + +export interface StatusBuildOptions { + readonly outDir: string; + readonly state: StaticState; + /** The live site node: its name and default locale (§1.6: no logo, no theme tokens). */ + readonly site: Pick; + /** Injected clock (RFC-0008 §9.4). */ + readonly now: number; +} + +/** What a static host should send with the page (§1.2), as a `_headers` file. */ +export function statusHeadersFile(state: StaticState): string { + return [ + "/*", + " Cache-Control: no-store", + " X-Robots-Tag: noindex", + ` X-Cloudhal-State: ${state}`, + " X-Content-Type-Options: nosniff", + " Referrer-Policy: no-referrer", + ` Content-Security-Policy: ${STATUS_CSP}`, + "", + ].join("\n"); +} + +/** `Retry-After` for the static state, which the web server sends with its 503 (§1.2). */ +export const staticRetryAfter = (state: StaticState): string => + retryAfter(state, { now: 0 }) ?? "3600"; + +/** + * Replaces `outDir` with the status output. The folder is removed first, as an Astro build + * empties it: a link at `outDir` is removed as a link, never followed. + */ +export async function writeStatusBuild(options: StatusBuildOptions): Promise { + const { outDir, state, site } = options; + const html = statusPageHtml(state, { + site: { name: site.name }, + locale: site.defaultLocale || "en", + now: options.now, + }); + const files: Record = { + "index.html": html, + "404.html": html, + "robots.txt": "User-agent: *\nDisallow: /\n", + _headers: statusHeadersFile(state), + [STATUS_MARKER]: `${state}\n`, + }; + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + await writeFile(path.join(outDir, name), content); + } + return Object.keys(files); +} diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 366c58e5..57b154cb 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { Snapshot } from "@cloudhal/core-contracts"; +import type { Site, Snapshot } from "@cloudhal/core-contracts"; import { type LocalStore, openLocalStore } from "@cloudhal/core-ts/local"; import { readProjectConfig } from "../config.ts"; @@ -71,3 +71,26 @@ export async function resolveBuildSnapshot(projectDir: string): Promise { + const cfg = await readProjectConfig(projectDir); + const local = await openLocalStore(path.join(projectDir, cfg.dataDir)); + try { + const site = await local.store.getSite(cfg.siteId); + if (!site) throw new Error(`site ${cfg.siteId} not found`); + if (!site.publishedSnapshotId) return { unpublished: site }; + const s = await local.store.getSnapshot(site.publishedSnapshotId); + if (!s) throw new Error(`published snapshot ${site.publishedSnapshotId} not found`); + return { snapshot: s }; + } finally { + await local.close(); + } +} diff --git a/packages/cli/test/build-published.test.ts b/packages/cli/test/build-published.test.ts new file mode 100644 index 00000000..af4381d2 --- /dev/null +++ b/packages/cli/test/build-published.test.ts @@ -0,0 +1,163 @@ +/** + * `cloudhal build --published` (RFC-0043 §1.10, rollout PR 2): a site that was never published + * builds the `not-published` status page as its whole output — never the latest snapshot or the + * drafts — with the marker the Docker image's Caddyfile turns into a 503; once published, the + * published snapshot is built and the marker is gone. The status output needs no Astro run, so + * its cases run in this process; the published build spawns the bin as old-theme-compat does. + */ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { STATUS_CSP } from "@cloudhal/astro-renderer/status-page"; +import { openLocalStore } from "@cloudhal/core-ts/local"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + STATUS_MARKER, + statusHeadersFile, + writeStatusBuild, +} from "../src/commands/build-status.ts"; +import { build, init, readProjectConfig, snapshot } from "../src/index.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const STARTER_NODE_MODULES = path.resolve(here, "../../../starters/blog/node_modules"); +const BIN = path.resolve(here, "../bin/cloudhal.ts"); +const TITLE = "Unpublished Site"; +const STATUS_FILES = [STATUS_MARKER, "404.html", "_headers", "index.html", "robots.txt"]; + +async function runCli( + projectDir: string, + args: string[], +): Promise<{ code: number | null; output: string }> { + const { VITEST: _v, NODE_ENV: _n, ...env } = process.env; + const child = spawn(process.execPath, [BIN, ...args], { + cwd: projectDir, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout?.on("data", (c: Buffer) => (output += c.toString())); + child.stderr?.on("data", (c: Buffer) => (output += c.toString())); + const code = await new Promise((resolve) => child.once("exit", resolve)); + return { code, output }; +} + +describe("writeStatusBuild", () => { + let tmp: string; + beforeAll(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-status-build-")); + }); + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it("writes the page in the site's default locale, with its headers and marker", async () => { + const outDir = path.join(tmp, "tr"); + const files = await writeStatusBuild({ + outDir, + state: "not-published", + site: { name: "Türkçe Site", defaultLocale: "tr" }, + now: 0, + }); + expect([...files].sort()).toEqual(STATUS_FILES); + expect((await readdir(outDir)).sort()).toEqual(STATUS_FILES); + const html = await readFile(path.join(outDir, "index.html"), "utf8"); + expect(html).toContain(''); + expect(await readFile(path.join(outDir, "404.html"), "utf8")).toBe(html); + expect(await readFile(path.join(outDir, "robots.txt"), "utf8")).toBe( + "User-agent: *\nDisallow: /\n", + ); + expect(await readFile(path.join(outDir, STATUS_MARKER), "utf8")).toBe("not-published\n"); + const headers = await readFile(path.join(outDir, "_headers"), "utf8"); + expect(headers).toBe(statusHeadersFile("not-published")); + expect(headers).toContain("/*\n Cache-Control: no-store\n X-Robots-Tag: noindex\n"); + expect(headers).toContain(` Content-Security-Policy: ${STATUS_CSP}\n`); + }); + + it("empties the folder first and removes a link there without following it", async () => { + const served = path.join(tmp, "served"); + await mkdir(served); + await writeFile(path.join(served, "keep.html"), "the served build"); + const outDir = path.join(tmp, "linked"); + await symlink(served, outDir, "dir"); + await writeStatusBuild({ + outDir, + state: "not-published", + site: { name: "Site", defaultLocale: "en" }, + now: 0, + }); + expect((await readdir(outDir)).sort()).toEqual(STATUS_FILES); + expect(await readFile(path.join(served, "keep.html"), "utf8")).toBe("the served build"); + }); + + it("falls back to English for a locale without a catalog", async () => { + const outDir = path.join(tmp, "xx"); + await writeStatusBuild({ + outDir, + state: "not-published", + site: { name: "Site", defaultLocale: "xx-YY" }, + now: 0, + }); + expect(await readFile(path.join(outDir, "index.html"), "utf8")).toContain(' { + let tmp: string; + let projectDir: string; + let dist: string; + beforeAll(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-build-published-")); + projectDir = path.join(tmp, "site"); + await init({ dir: projectDir, name: TITLE }); + await symlink(STARTER_NODE_MODULES, path.join(projectDir, "node_modules"), "dir"); + dist = path.join(projectDir, "dist"); + }); + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it("refuses --snapshot alongside it", async () => { + await expect(build({ projectDir, published: true, snapshotId: "snap_x" })).rejects.toThrow( + /--published/, + ); + }); + + it("writes the status page while nothing is published: no snapshot, no drafts", async () => { + // a stale build in dist is replaced, as an Astro build replaces it + await mkdir(dist, { recursive: true }); + await writeFile(path.join(dist, "stale.html"), "old"); + const { code, output } = await runCli(projectDir, ["build", "--published"]); + expect(code, output).toBe(0); + expect(output).toContain("not-published: nothing is published yet"); + expect(output).toContain("Retry-After: 3600"); + expect((await readdir(dist)).sort()).toEqual(STATUS_FILES); + const html = await readFile(path.join(dist, "index.html"), "utf8"); + expect(html).toContain(TITLE); + expect(html).toContain(' { + const snap = await snapshot({ projectDir, publish: true, label: "launch" }); + const { code, output } = await runCli(projectDir, ["build", "--published"]); + expect(code, output.slice(-4000)).toBe(0); + expect(output).toContain(`from ${snap.id} (published)`); + const files = await readdir(dist); + expect(files).not.toContain(STATUS_MARKER); + const home = await readFile(path.join(dist, "index.html"), "utf8"); + expect(home).toMatch(/]*>Unpublished Site<\/h1>/); + expect(home).toContain('href="/posts/hello-world"'); + }, 180_000); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ff31598..b1aa232e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -745,6 +745,9 @@ importers: '@cloudhal/api': specifier: workspace:* version: link:../api + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../astro-renderer '@cloudhal/cloudflare': specifier: workspace:* version: link:../cloudflare diff --git a/scripts/docker-smoke.ts b/scripts/docker-smoke.ts index 22dd2ee8..b165fc3e 100644 --- a/scripts/docker-smoke.ts +++ b/scripts/docker-smoke.ts @@ -2,13 +2,19 @@ * What a self-hoster gets (docs/guides/self-host.md, #143): runs the image with an empty volume, * waits for its HEALTHCHECK, then — through the API, as Studio would — checks that the first owner * can sign up only with the setup token (#165), that Studio's page is served and that `/mcp` - * refuses an anonymous call, lists the scaffolded site, publishes it, builds the public site with - * `site-build` twice (the second build swaps the served tree in place, #166) and reads the built - * home page. Run by .github/workflows/docker.yml after every build, and by hand: + * refuses an anonymous call, lists the scaffolded site, builds the public site with `site-build` + * before the first publish (RFC-0043's status page, which the compose file's Caddyfile — run in a + * `caddy` container on the same volume — answers with 503 and `Retry-After`), publishes it, builds + * it twice more (the site, which Caddy now answers with 200; the third build swaps the served tree + * in place, #166) and reads the built home page. Run by .github/workflows/docker.yml after every + * build, and by hand: * - * node scripts/docker-smoke.ts [--image ] [--platform ] [--port ] [--keep] + * node scripts/docker-smoke.ts [--image ] [--platform ] [--port ] + * [--site-port ] [--keep] */ import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; const arg = (name: string): string | undefined => { const i = process.argv.indexOf(`--${name}`); @@ -18,8 +24,17 @@ const image = arg("image") ?? "cloudhal:local"; const platform = arg("platform"); const port = Number(arg("port") ?? 18787); const keep = process.argv.includes("--keep"); +const sitePort = Number(arg("site-port") ?? port + 1); const name = `cloudhal-smoke-${process.pid}`; +const caddyName = `${name}-caddy`; const origin = `http://localhost:${port}`; +const siteOrigin = `http://127.0.0.1:${sitePort}`; +/** The compose file's Caddyfile and the Caddy it runs (compose.yml, the `caddy` profile). */ +const CADDYFILE = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../deploy/docker/Caddyfile", +); +const CADDY_IMAGE = "caddy:2.11"; // #165: the first account needs the setup token; the container gets one so the test need not read // the printed link from its logs. const setupToken = `smoke-setup-token-${crypto.randomUUID()}`; @@ -82,6 +97,60 @@ function siteBuild(which: string): string { return docker(["exec", name, "readlink", "/data/site/current"]); } +/** + * Caddy with the compose file's Caddyfile, the installation's volume at /srv/cloudhal (read-only, + * as compose.yml mounts it) and plain HTTP: `:8080` is the site, `:8081` the unused Studio host. + */ +async function startCaddy(): Promise { + const volume = docker([ + "inspect", + "--format", + '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}', + name, + ]); + expect(volume !== "", "the container has a /data volume"); + docker([ + "run", + "-d", + "--name", + caddyName, + "-p", + `127.0.0.1:${sitePort}:8080`, + "-e", + "CLOUDHAL_SITE_URL=:8080", + "-e", + "CLOUDHAL_BASE_URL=:8081", + "-v", + `${CADDYFILE}:/etc/caddy/Caddyfile:ro`, + "-v", + `${volume}:/srv/cloudhal:ro`, + CADDY_IMAGE, + "caddy", + "run", + "--config", + "/etc/caddy/Caddyfile", + "--adapter", + "caddyfile", + ]); + for (let i = 0; i < 30; i++) { + try { + await fetch(`${siteOrigin}/robots.txt`); + return; + } catch { + await sleep(1000); + } + } + throw new Error("Caddy did not answer within 30 s"); +} + +/** A page of the public site as Caddy serves it. */ +async function served( + pathname: string, +): Promise<{ status: number; headers: Headers; body: string }> { + const res = await fetch(`${siteOrigin}${pathname}`); + return { status: res.status, headers: res.headers, body: await res.text() }; +} + docker([ "run", "-d", @@ -154,32 +223,77 @@ try { const site = (sites.body as { id: string; name: string }[])[0]; if (site?.name !== "Smoke Site") expect(false, "the scaffolded site is listed", sites.body); if (!site) throw new Error("no site"); - const publish = await api("POST", `/api/sites/${site.id}/snapshots`, { - cookie, - body: { publish: true, label: "smoke" }, - }); - expect(publish.status === 200 || publish.status === 201, "publish", publish.body); - step(`site ${site.id} published`); - // A volume from 0.6.3 has a real /data/dist folder: the first build moves it aside, keeps it as // site/previous, and puts the symlink in its place (#166). docker(["exec", name, "sh", "-c", "mkdir -p /data/dist && touch /data/dist/marker"]); - const first = siteBuild("first"); + // RFC-0043 §1.10: before the first publish the build is the status page and its marker, never + // the drafts, and Caddy answers it with 503 and Retry-After (robots.txt keeps its 200). + const first = siteBuild("before the first publish"); const resolved = docker(["exec", name, "readlink", "-f", SITE]); expect(resolved.startsWith("/data/site/"), `${SITE} resolves under /data/site/`, resolved); const legacy = spawnSync("docker", ["exec", name, "test", "-e", "/data/site/previous/marker"]); expect(legacy.status === 0, "site/previous is the moved 0.6.3 folder, marker included"); step("the first site-build moved a 0.6.3 /data/dist aside as site/previous"); + const marker = docker(["exec", name, "cat", `${SITE}/.cloudhal-status`]); + expect(marker === "not-published", "the unpublished build has the not-published marker", marker); + const statusHtml = docker(["exec", name, "cat", `${SITE}/index.html`]); + expect( + statusHtml.includes('data-state="not-published"') && !HOME_H1.test(statusHtml), + "the unpublished build is the status page, not the site", + ); + await startCaddy(); + const unpublished = await served("/"); + expect( + unpublished.status === 503, + "Caddy answers / with 503 before the first publish", + unpublished.status, + ); + expect( + unpublished.headers.get("retry-after") === "3600", + "Retry-After: 3600", + unpublished.headers.get("retry-after"), + ); + expect( + unpublished.headers.get("cache-control") === "no-store", + "Cache-Control: no-store", + unpublished.headers.get("cache-control"), + ); + expect(unpublished.headers.get("x-robots-tag") === "noindex", "X-Robots-Tag: noindex"); + expect(unpublished.body.includes('data-state="not-published"'), "the 503 is the status page"); + const deep = await served("/posts/hello-world"); + expect(deep.status === 503, "every page is the 503 before the first publish", deep.status); + const robots = await served("/robots.txt"); + expect( + robots.status === 200 && robots.body.includes("Disallow: /"), + "robots.txt is 200 and disallows all", + robots, + ); + step("before the first publish Caddy serves the status page with 503 and Retry-After"); + + const publish = await api("POST", `/api/sites/${site.id}/snapshots`, { + cookie, + body: { publish: true, label: "smoke" }, + }); + expect(publish.status === 200 || publish.status === 201, "publish", publish.body); + step(`site ${site.id} published`); + + const second = siteBuild("after the publish"); + expect(second !== first, "the build after the publish is a new tree", { first, second }); + const noMarker = spawnSync("docker", ["exec", name, "test", "-e", `${SITE}/.cloudhal-status`]); + expect(noMarker.status !== 0, "the published build has no status marker"); expect( HOME_H1.test(docker(["exec", name, "cat", `${SITE}/index.html`])), "the built home page shows the site name", ); - step(`site-build published the site: ${SITE} -> site/current -> ${first}`); + const published = await served("/"); + expect(published.status === 200, "Caddy answers / with 200 after the publish", published.status); + expect(HOME_H1.test(published.body), "Caddy serves the built home page"); + step(`site-build published the site, served with 200: ${SITE} -> site/current -> ${second}`); - // A second build swaps the served tree in one rename and keeps the one before it, whose pages + // A third build swaps the served tree in one rename and keeps the one before it, whose pages // may still load their assets (#166). - const second = siteBuild("second"); - expect(second !== first, "the second build is a new tree", { first, second }); + const third = siteBuild("third"); + expect(third !== second, "the third build is a new tree", { second, third }); const kept = docker([ "exec", name, @@ -188,23 +302,30 @@ try { "ls -1 /data/site | grep -v -x -e current -e previous", ]); expect( - kept === [first, second].sort().join("\n"), + kept === [second, third].sort().join("\n"), "the newest build and the one before it are kept", kept, ); const previous = docker(["exec", name, "readlink", "/data/site/previous"]); - expect(previous === first, "site/previous links to the first build", previous); + expect(previous === second, "site/previous links to the build before", previous); expect( HOME_H1.test(docker(["exec", name, "cat", `${SITE}/index.html`])), "the home page is served after the swap", ); - step("a second site-build swapped the served tree and kept the previous one"); + expect((await served("/")).status === 200, "Caddy still answers / with 200 after the swap"); + step("a third site-build swapped the served tree and kept the previous one"); console.log("\nDocker smoke test passed."); } catch (e) { console.error(`\n✗ ${e instanceof Error ? e.message : String(e)}\n--- container logs ---`); spawnSync("docker", ["logs", "--tail", "200", name], { stdio: "inherit" }); + console.error("--- caddy logs ---"); + spawnSync("docker", ["logs", "--tail", "50", caddyName], { stdio: "inherit" }); process.exitCode = 1; } finally { - if (keep) console.log(`kept container ${name}`); - else spawnSync("docker", ["rm", "-f", "-v", name], { stdio: "ignore" }); + if (keep) console.log(`kept containers ${name} and ${caddyName}`); + else { + // Caddy first: the volume goes with the Cloudhal container, and only once nothing uses it. + spawnSync("docker", ["rm", "-f", caddyName], { stdio: "ignore" }); + spawnSync("docker", ["rm", "-f", "-v", name], { stdio: "ignore" }); + } } From d3e22eb809e790aef9e4c4aec6c0a630ff17f606 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:24:51 +0300 Subject: [PATCH 2/2] fix(docker): never-latest tests, older CLIs, a site build at start (RFC-0043 PR 2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-published.test.ts proves "never the latest": - an unpublished snapshot before the first publish still gives the status page, which does not name that snapshot; - a draft rename after the publish stays out of the build; - a rollback from s2 to s1 builds s1. - site-build passes --published only when `cloudhal build --help` lists it. A CLI from 0.7.1 or older, in an image built from this checkout with CLOUDHAL_VERSION, gets one warning and the old call. - entrypoint runs one site-build in the background once Studio answers and /data/cloudhal.json exists. tini reaps it, not `cloudhal studio`. A never-published volume upgraded from 0.6.4–0.7.1 switches at once to the 503 status page, a new volume gets it on its first start, and a failed build only logs. - docker-smoke waits for the start-up build and takes a draft snapshot before the explicit first build, which must still be the 503. Where the CLI has no --published, it skips the status-page checks. The 0.6.3 folder is simulated after the start-up build. - self-host guide (upgrades, building the image), README, docker.yml backfill note and the changeset follow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/build-published-status.md | 2 +- .github/workflows/docker.yml | 5 +- deploy/docker/README.md | 4 +- deploy/docker/bin/entrypoint | 23 ++++ deploy/docker/bin/site-build | 17 ++- docs/guides/self-host.md | 8 +- packages/cli/test/build-published.test.ts | 71 +++++++++-- scripts/docker-smoke.ts | 148 +++++++++++++++------- 8 files changed, 207 insertions(+), 71 deletions(-) diff --git a/.changeset/build-published-status.md b/.changeset/build-published-status.md index a5907238..58d7840d 100644 --- a/.changeset/build-published-status.md +++ b/.changeset/build-published-status.md @@ -2,4 +2,4 @@ "@cloudhal/cli": patch --- -`cloudhal build --published` (RFC-0043 §1.10) builds the published snapshot only, never the latest snapshot or the drafts. When nothing is published yet, it writes the `not-published` status page as the whole output instead, in the site's default language: `index.html` and `404.html`, a `robots.txt` that disallows everything, a `_headers` file with `no-store` and `noindex`, and a `.cloudhal-status` marker. The Docker image's `site-build` now passes the flag, and the compose file's Caddyfile answers every path except `robots.txt` with that page, status 503 and `Retry-After: 3600` while the marker exists. So a fresh self-hosted site no longer serves drafts before its first publish. `cloudhal build` without the flag is unchanged. +`cloudhal build --published` (RFC-0043 §1.10) builds the published snapshot only, never the latest snapshot or the drafts. When nothing is published yet, it writes the `not-published` status page as the whole output instead, in the site's default language: `index.html` and `404.html`, a `robots.txt` that disallows everything, a `_headers` file with `no-store` and `noindex`, and a `.cloudhal-status` marker. The Docker image's `site-build` now passes the flag, and the compose file's Caddyfile answers every path except `robots.txt` with that page, status 503 and `Retry-After: 3600` while the marker exists. The image also runs `site-build` once in the background when Studio starts, so a fresh self-hosted site no longer serves drafts before its first publish, and a volume upgraded from 0.7.1 or older switches on its first start. `cloudhal build` without the flag is unchanged. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 428fdb1d..20085bad 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,7 +24,10 @@ name: Docker image # - a pull request that touches the image builds it for linux/amd64 without pushing and runs the # amd64 smoke test and the scan. # Versions before 0.6.4 cannot pass the gates: the smoke test expects the first owner's setup -# token (#165), which 0.6.4 brought, and the image needs `--init-if-empty` (0.6.3). +# token (#165), which 0.6.4 brought, and the image needs `--init-if-empty` (0.6.3). A backfill of +# 0.6.4–0.7.1 passes without the RFC-0043 status page: its CLI has no `cloudhal build --published`, +# so `site-build` (from this checkout) warns and builds the old way, and the smoke test skips its +# status-page checks. on: workflow_call: diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 83f7db71..6ab43a32 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -3,7 +3,7 @@ The official self-host image, `ghcr.io/cloudhal-dev/cloudhal`, built from each npm release (issue #143). The guide is [docs/guides/self-host.md](../../docs/guides/self-host.md); this folder holds what it refers to: - `Dockerfile` — `node:24-bookworm-slim`, `@cloudhal/cli` installed with npm (`CLOUDHAL_VERSION`), the blog starter's dependencies next to it, non-root, `/data` volume, port 8787, a health check on `/api/health`. `install.mjs` is its build stage. -- `bin/entrypoint` — `cloudhal studio --init-if-empty` in `/data`; `bin/site-build` — the public site as static HTML of the published snapshot (`cloudhal build --published`; before the first publish the not-published status page with a `.cloudhal-status` marker, which the `Caddyfile` answers with 503), built in a staging copy of the project and published at `/data/dist`, a symlink to the newest build under `/data/site` (swapped atomically, never a 404 — except for a moment on the first build after upgrading from 0.6.3, when the real folder moves aside to `/data/site/legacy-0.6.3`; the build before the current one is kept as `/data/site/previous`). Never run an Astro build (`cloudhal build`, `cloudhal deploy`) inside `/data` by hand: it empties the served build through the symlink. +- `bin/entrypoint` — `cloudhal studio --init-if-empty` in `/data`, and one `site-build` in the background once Studio answers; `bin/site-build` — the public site as static HTML of the published snapshot (`cloudhal build --published`; before the first publish the not-published status page with a `.cloudhal-status` marker, which the `Caddyfile` answers with 503), built in a staging copy of the project and published at `/data/dist`, a symlink to the newest build under `/data/site` (swapped atomically, never a 404 — except for a moment on the first build after upgrading from 0.6.3, when the real folder moves aside to `/data/site/legacy-0.6.3`; the build before the current one is kept as `/data/site/previous`). Never run an Astro build (`cloudhal build`, `cloudhal deploy`) inside `/data` by hand: it empties the served build through the symlink. - `compose.yml`, `.env.example`, `Caddyfile` — Studio alone, or with Caddy (profile `caddy`) serving Studio and the built site with automatic TLS. Build it yourself from a release, or from this checkout's packages (`edge`): @@ -16,4 +16,6 @@ docker build --build-arg CLOUDHAL_VERSION=0.6.4 -t cloudhal:local deploy/docker node scripts/pack-packages.ts deploy/docker/tarballs && docker build -t cloudhal:local deploy/docker ``` +A release whose CLI has no `cloudhal build --published` (0.7.1 or older) still builds with these scripts: `site-build` warns and builds the old way, and the smoke test skips its status-page checks. + `node scripts/docker-smoke.ts --image cloudhal:local` runs what CI runs after every build (`.github/workflows/docker.yml`); `--platform linux/arm64` runs the other architecture where Docker can. diff --git a/deploy/docker/bin/entrypoint b/deploy/docker/bin/entrypoint index dbbcd418..8c98c615 100755 --- a/deploy/docker/bin/entrypoint +++ b/deploy/docker/bin/entrypoint @@ -23,6 +23,29 @@ if [ "${1:-studio}" = "studio" ]; then esac ;; esac + # One site build per start, once Studio answers (it migrates the database, and on an empty + # volume creates the project, first; the build reads the same database). A volume upgraded from + # an image that built drafts before the first publish then switches at once to the published + # build or the 503 status page, and a new volume gets the status page, without waiting for the + # next `site-build`. In the background, so Studio never waits for it; the outer subshell exits at + # once, so tini (PID 1) reaps the build rather than `cloudhal studio`. A failure changes nothing + # (site-build publishes only a finished build) and is only logged. + ( + ( + i=0 + until node -e "fetch('http://127.0.0.1:8787/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" 2>/dev/null; do + i=$((i + 1)) + if [ "$i" -ge 90 ]; then + echo "entrypoint: Studio did not answer within 180 s; no start-up site-build" >&2 + exit 0 + fi + sleep 2 + done + [ -f /data/cloudhal.json ] || exit 0 + echo "entrypoint: start-up site-build" + site-build || echo "entrypoint: the start-up site-build failed; the served site is unchanged" >&2 + ) & + ) [ $# -gt 0 ] && shift exec cloudhal studio --init-if-empty "$@" fi diff --git a/deploy/docker/bin/site-build b/deploy/docker/bin/site-build index 1fd1edb2..a870559c 100755 --- a/deploy/docker/bin/site-build +++ b/deploy/docker/bin/site-build @@ -22,9 +22,11 @@ # unpublished snapshots never reach the public site. Before the first publish the build is the # not-published status page with a `.cloudhal-status` marker, which the compose file's Caddyfile # answers with 503 and Retry-After; the first build after a publish replaces it like any other. -# Arguments go to `cloudhal build` after `--published` (so `--snapshot` is refused). The project's -# dependencies are the image's node_modules, linked into the project on first use, so no install -# and no network is needed. +# Arguments go to `cloudhal build` after `--published` (so `--snapshot` is refused). An image built +# from this script with a CLI from before the flag (`CLOUDHAL_VERSION` 0.7.1 or older: the scripts +# come from the checkout, the CLI from npm) warns once and builds the old way: the published +# snapshot, else the latest. The project's dependencies are the image's node_modules, linked into +# the project on first use, so no install and no network is needed. set -eu cd /data if [ ! -f cloudhal.json ]; then @@ -124,7 +126,14 @@ for entry in .[!.]* ..?* *; do *) cp -a "$entry" "$stage/" ;; esac done -(cd "$stage" && cloudhal build --published "$@") +# `--published` when this CLI has it (`cloudhal build --help` lists every flag). +if cloudhal build --help 2>/dev/null | grep -q -e '--published'; then + only=--published +else + only="" + echo "site-build: this cloudhal CLI has no \`build --published\` (0.7.1 or older): building the published snapshot, else the latest one, and no status page before the first publish" >&2 +fi +(cd "$stage" && cloudhal build $only "$@") # Publish: the finished build → /data/site/, then each pointer with one rename. id="$(date -u +%Y%m%dT%H%M%SZ).$$" diff --git a/docs/guides/self-host.md b/docs/guides/self-host.md index f9a86b3e..ce94e566 100644 --- a/docs/guides/self-host.md +++ b/docs/guides/self-host.md @@ -103,7 +103,7 @@ A one-off `docker compose --env-file … up -d` is not enough: the next command docker compose exec cloudhal site-build ``` -and Caddy (or your web server) serves `/data/dist` from the volume. `cloudhal build` always writes the project's `dist` folder and empties it first, so `site-build` never runs it in `/data`: it copies the project's files to `/data/.site-build` (its `node_modules` and data folder linked, so the build reads the same database) and builds there. The finished build moves to `/data/site/`; then `/data/site/current`, `/data/site/previous` and `/data/dist` — symlinks to the newest build, the one before it, and `current` — are each replaced with one rename (`ln -sfn` and `mv -T`, which is atomic). Visitors see the previous build until the new one is complete, never a 404 or a half-written page — except for a moment on the first build after upgrading from 0.6.3, when the real `/data/dist` folder moves to `/data/site/legacy-0.6.3` (kept as `previous` for one build) and the symlink takes its place: the container's coreutils cannot swap a folder for a symlink in one step. A failed build changes nothing. The build before the current one is kept, and the compose file's Caddyfile serves a hashed asset (`/_astro/*`, `/_cloudhal/*`, `/media/*`) from it when the current build no longer has it, so a page opened before the swap still loads its scripts and media; older builds are removed. Never run an Astro build inside `/data` by hand (`cloudhal build`, `cloudhal deploy`): it would write through the `dist` symlink and empty the served build. Run `site-build` after each publish; a cron line on the host (`*/15 * * * * cd /path/to/cloudhal && docker compose exec -T cloudhal site-build`) keeps the site at most that far behind (`exec` runs in the running container, so it needs no `COMPOSE_ENV_FILES`). Drafts and scheduled releases show in Studio at once (the editor previews drafts); the public pages change when the next build runs. Signed preview links to unpublished content on the public site belong to the Cloudflare deployment; the container has no preview secret. Rebuilding on publish from inside the container is a follow-up issue; serving the site live from the snapshot, as the Cloudflare deployment does, is an RFC of its own. +and Caddy (or your web server) serves `/data/dist` from the volume. `cloudhal build` always writes the project's `dist` folder and empties it first, so `site-build` never runs it in `/data`: it copies the project's files to `/data/.site-build` (its `node_modules` and data folder linked, so the build reads the same database) and builds there. The finished build moves to `/data/site/`; then `/data/site/current`, `/data/site/previous` and `/data/dist` — symlinks to the newest build, the one before it, and `current` — are each replaced with one rename (`ln -sfn` and `mv -T`, which is atomic). Visitors see the previous build until the new one is complete, never a 404 or a half-written page — except for a moment on the first build after upgrading from 0.6.3, when the real `/data/dist` folder moves to `/data/site/legacy-0.6.3` (kept as `previous` for one build) and the symlink takes its place: the container's coreutils cannot swap a folder for a symlink in one step. A failed build changes nothing. The build before the current one is kept, and the compose file's Caddyfile serves a hashed asset (`/_astro/*`, `/_cloudhal/*`, `/media/*`) from it when the current build no longer has it, so a page opened before the swap still loads its scripts and media; older builds are removed. Never run an Astro build inside `/data` by hand (`cloudhal build`, `cloudhal deploy`): it would write through the `dist` symlink and empty the served build. The container runs `site-build` once at every start, in the background once Studio answers. Run it after each publish; a cron line on the host (`*/15 * * * * cd /path/to/cloudhal && docker compose exec -T cloudhal site-build`) keeps the site at most that far behind (`exec` runs in the running container, so it needs no `COMPOSE_ENV_FILES`). Drafts and scheduled releases show in Studio at once (the editor previews drafts); the public pages change when the next build runs. Signed preview links to unpublished content on the public site belong to the Cloudflare deployment; the container has no preview secret. Rebuilding on publish from inside the container is a follow-up issue; serving the site live from the snapshot, as the Cloudflare deployment does, is an RFC of its own. **Before the first publish.** With nothing published, `cloudhal build --published` writes RFC-0043's `not-published` status page as the whole build: `index.html` and `404.html` are the page ("Coming soon" under the site's name, in the site's default language), `robots.txt` disallows every path, `_headers` sets `Cache-Control: no-store`, `X-Robots-Tag: noindex` and the page's own `Content-Security-Policy` for hosts that read it, and a marker file `.cloudhal-status` holds the state (`not-published`). `site-build` prints that it published the status page. The compose file's Caddyfile answers every path but `/robots.txt` with that page while the marker exists, with the headers the Cloudflare deployment sends for the same state: @@ -133,7 +133,7 @@ The image carries the same version as the npm packages: `0.6.4` is `@cloudhal/cl docker compose pull && docker compose up -d ``` -Database migrations run when the container starts, as they do on every `cloudhal studio` start. Going back to an older image over a newer database is not supported: take a backup first. +Database migrations run when the container starts, as they do on every `cloudhal studio` start. Once Studio answers, the container also runs `site-build` once in the background (`docker compose logs cloudhal` shows `entrypoint: start-up site-build`), so the served site matches the new image right away. In particular, a volume that was never published and served a build of the drafts from an image up to 0.7.1 switches to the 503 status page on that start, without waiting for the next `site-build`. A failed start-up build leaves the served site as it was. Going back to an older image over a newer database is not supported: take a backup first. **From the compose files of 0.6.3.** The compose file now names the volume `cloudhal-data` outright, where the earlier file left the name to compose (`_cloudhal-data`; [issue #166](https://github.com/cloudhal-dev/cloudhal/issues/166)). In this order: @@ -166,7 +166,7 @@ docker run --rm --entrypoint grep "$(docker compose config --images | grep cloud ``` A number above 0 means 0.6.4 or later. A 0 means the image is still 0.6.3 (0.6.4 is not on your tag yet): the new files work with it too, `/data/dist` stays a real folder, and the layout changes with the first build of a 0.6.4 image. -5. **Start the stack** as usual (`docker compose up -d`, or `docker compose --profile caddy up -d`). +5. **Start the stack** as usual (`docker compose up -d`, or `docker compose --profile caddy up -d`). The container builds the site once Studio answers (the start-up `site-build`, above), which also moves the 0.6.3 folder aside. The site stays at `/data/dist` throughout. The 0.6.3 `site-build` wrote a real folder there. The first build of 0.6.4 moves it to `/data/site/legacy-0.6.3`, kept as `previous` for one build, and puts the symlink in its place: `/data/dist` is missing only between those two renames. An old Caddyfile or an nginx root of `/data/dist` keeps working. @@ -243,4 +243,4 @@ docker compose cp cloudhal:/data/backup.cloudhal . docker build --build-arg CLOUDHAL_VERSION=0.6.4 -t cloudhal:local deploy/docker ``` -builds a release version from npm (0.6.4 or later: the smoke test expects the setup token); `node scripts/pack-packages.ts deploy/docker/tarballs` followed by the same build without the argument builds `edge` from a checkout. `node scripts/docker-smoke.ts --image cloudhal:local` runs what CI runs: an empty volume, the health check, Studio's page, the first owner through the setup token (refused without it), an anonymous `/mcp` call, `site-build` before the first publish (the status page, which the compose file's Caddyfile, run in a `caddy:2.11` container on the same volume, answers with 503 and `Retry-After`), a publish and `site-build` twice more (the site, served with 200; the third build swaps the served tree). `--platform linux/arm64` runs the other architecture's image where Docker can (Apple silicon, or QEMU). +builds a release version from npm (0.6.4 or later: the smoke test expects the setup token). The image's scripts come from the checkout and the CLI from npm, so a release whose CLI has no `cloudhal build --published` (0.7.1 or older) still builds: its `site-build` prints a warning and builds the old way (the published snapshot, else the latest, and no status page), and the smoke test skips its status-page checks; `node scripts/pack-packages.ts deploy/docker/tarballs` followed by the same build without the argument builds `edge` from a checkout. `node scripts/docker-smoke.ts --image cloudhal:local` runs what CI runs: an empty volume, the health check, Studio's page, the first owner through the setup token (refused without it), an anonymous `/mcp` call, the start-up build, a draft snapshot, `site-build` before the first publish (the status page, which the compose file's Caddyfile, run in a `caddy:2.11` container on the same volume, answers with 503 and `Retry-After`), a publish and `site-build` twice more (the site, served with 200; the third build swaps the served tree). `--platform linux/arm64` runs the other architecture's image where Docker can (Apple silicon, or QEMU). diff --git a/packages/cli/test/build-published.test.ts b/packages/cli/test/build-published.test.ts index af4381d2..267354e7 100644 --- a/packages/cli/test/build-published.test.ts +++ b/packages/cli/test/build-published.test.ts @@ -11,7 +11,8 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { STATUS_CSP } from "@cloudhal/astro-renderer/status-page"; -import { openLocalStore } from "@cloudhal/core-ts/local"; +import type { Snapshot } from "@cloudhal/core-contracts"; +import { type LocalStore, openLocalStore } from "@cloudhal/core-ts/local"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { STATUS_MARKER, @@ -109,6 +110,30 @@ describe("cloudhal build --published", () => { let tmp: string; let projectDir: string; let dist: string; + /** The snapshot the second case publishes; the rollback case returns to it. */ + let published: Snapshot | undefined; + const DRAFT_NAME = "Renamed In Draft"; + + async function withStore( + fn: (store: LocalStore["store"], siteId: string) => Promise, + ): Promise { + const cfg = await readProjectConfig(projectDir); + const local = await openLocalStore(path.join(projectDir, cfg.dataDir)); + try { + return await fn(local.store, cfg.siteId); + } finally { + await local.close(); + } + } + + /** A draft change the home page shows: the starter's `

` is the site name. */ + const renameSite = (name: string) => + withStore(async (store, siteId) => { + const site = await store.getSite(siteId); + if (!site) throw new Error("no site"); + await store.updateSite({ ...site, name }); + }); + beforeAll(async () => { tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-build-published-")); projectDir = path.join(tmp, "site"); @@ -126,38 +151,58 @@ describe("cloudhal build --published", () => { ); }); - it("writes the status page while nothing is published: no snapshot, no drafts", async () => { + it("writes the status page while nothing is published, never the latest snapshot", async () => { // a stale build in dist is replaced, as an Astro build replaces it await mkdir(dist, { recursive: true }); await writeFile(path.join(dist, "stale.html"), "old"); + // a snapshot nobody published: plain `cloudhal build` would render it + const draft = await snapshot({ projectDir, label: "draft" }); const { code, output } = await runCli(projectDir, ["build", "--published"]); expect(code, output).toBe(0); expect(output).toContain("not-published: nothing is published yet"); expect(output).toContain("Retry-After: 3600"); + expect(output).not.toContain(draft.id); expect((await readdir(dist)).sort()).toEqual(STATUS_FILES); const html = await readFile(path.join(dist, "index.html"), "utf8"); + expect(html).toContain('data-state="not-published"'); expect(html).toContain(TITLE); expect(html).toContain(' store.latestSnapshot(siteId)))?.id).toBe(draft.id); }); - it("builds the published snapshot once there is one, without the marker", async () => { - const snap = await snapshot({ projectDir, publish: true, label: "launch" }); + it("builds the published snapshot, not a later unpublished one, without the marker", async () => { + published = await snapshot({ projectDir, publish: true, label: "launch" }); + await renameSite(DRAFT_NAME); + const later = await snapshot({ projectDir, label: "draft rename" }); const { code, output } = await runCli(projectDir, ["build", "--published"]); expect(code, output.slice(-4000)).toBe(0); - expect(output).toContain(`from ${snap.id} (published)`); + expect(output).toContain(`from ${published.id} (published)`); + expect(output).not.toContain(later.id); const files = await readdir(dist); expect(files).not.toContain(STATUS_MARKER); const home = await readFile(path.join(dist, "index.html"), "utf8"); expect(home).toMatch(/]*>Unpublished Site<\/h1>/); expect(home).toContain('href="/posts/hello-world"'); + expect(home).not.toContain(DRAFT_NAME); + }, 180_000); + + it("follows a rollback: publish s1, publish s2, roll back to s1, build s1", async () => { + const s1 = published; + if (!s1) throw new Error("the previous case publishes s1"); + const s2 = await snapshot({ projectDir, publish: true, label: "second" }); // the rename + expect(s2.id).not.toBe(s1.id); + await withStore(async (store, siteId) => { + const site = await store.getSite(siteId); + if (!site) throw new Error("no site"); + await store.rollback(site, s1.id, { kind: "human", id: "user_localcli0001", name: "test" }); + }); + const { code, output } = await runCli(projectDir, ["build", "--published"]); + expect(code, output.slice(-4000)).toBe(0); + expect(output).toContain(`from ${s1.id} (published)`); + const home = await readFile(path.join(dist, "index.html"), "utf8"); + expect(home).toMatch(/]*>Unpublished Site<\/h1>/); + expect(home).not.toContain(DRAFT_NAME); }, 180_000); }); diff --git a/scripts/docker-smoke.ts b/scripts/docker-smoke.ts index b165fc3e..31b82224 100644 --- a/scripts/docker-smoke.ts +++ b/scripts/docker-smoke.ts @@ -2,12 +2,15 @@ * What a self-hoster gets (docs/guides/self-host.md, #143): runs the image with an empty volume, * waits for its HEALTHCHECK, then — through the API, as Studio would — checks that the first owner * can sign up only with the setup token (#165), that Studio's page is served and that `/mcp` - * refuses an anonymous call, lists the scaffolded site, builds the public site with `site-build` - * before the first publish (RFC-0043's status page, which the compose file's Caddyfile — run in a - * `caddy` container on the same volume — answers with 503 and `Retry-After`), publishes it, builds - * it twice more (the site, which Caddy now answers with 200; the third build swaps the served tree - * in place, #166) and reads the built home page. Run by .github/workflows/docker.yml after every - * build, and by hand: + * refuses an anonymous call, lists the scaffolded site, waits for the entrypoint's start-up + * `site-build`, takes a draft snapshot and builds the public site with `site-build` before the + * first publish (RFC-0043's status page, never the draft, which the compose file's Caddyfile — run + * in a `caddy` container on the same volume — answers with 503 and `Retry-After`), publishes it, + * builds it twice more (the site, which Caddy now answers with 200; the third build swaps the + * served tree in place, #166) and reads the built home page. An image whose CLI has no + * `cloudhal build --published` (a backfill of 0.7.1 or older) skips the status-page checks: + * its `site-build` builds the old way. Run by .github/workflows/docker.yml after every build, and + * by hand: * * node scripts/docker-smoke.ts [--image ] [--platform ] [--port ] * [--site-port ] [--keep] @@ -88,6 +91,25 @@ function expect(ok: boolean, what: string, detail?: unknown): void { if (!ok) throw new Error(`${what}${detail === undefined ? "" : `: ${JSON.stringify(detail)}`}`); } +/** + * The entrypoint builds the site once Studio answers (a background `site-build`): wait until it + * has published (`/data/dist` is its symlink) and cleaned up (the staging folder is gone). + */ +async function waitStartupBuild(): Promise { + for (let i = 0; i < 90; i++) { + const done = spawnSync("docker", [ + "exec", + name, + "sh", + "-c", + "test -L /data/dist && test ! -e /data/.site-build", + ]); + if (done.status === 0) return; + await sleep(2000); + } + throw new Error("the start-up site-build did not finish within 180 s"); +} + /** Runs `site-build` in the container; returns the build `current` points at afterwards. */ function siteBuild(which: string): string { const r = spawnSync("docker", ["exec", name, "site-build"], { stdio: "inherit" }); @@ -151,6 +173,36 @@ async function served( return { status: res.status, headers: res.headers, body: await res.text() }; } +/** RFC-0043 §1.10: what Caddy answers while the build is the not-published status page. */ +async function expectStatusPage(): Promise { + const unpublished = await served("/"); + expect( + unpublished.status === 503, + "Caddy answers / with 503 before the first publish", + unpublished.status, + ); + expect( + unpublished.headers.get("retry-after") === "3600", + "Retry-After: 3600", + unpublished.headers.get("retry-after"), + ); + expect( + unpublished.headers.get("cache-control") === "no-store", + "Cache-Control: no-store", + unpublished.headers.get("cache-control"), + ); + expect(unpublished.headers.get("x-robots-tag") === "noindex", "X-Robots-Tag: noindex"); + expect(unpublished.body.includes('data-state="not-published"'), "the 503 is the status page"); + const deep = await served("/posts/hello-world"); + expect(deep.status === 503, "every page is the 503 before the first publish", deep.status); + const robots = await served("/robots.txt"); + expect( + robots.status === 200 && robots.body.includes("Disallow: /"), + "robots.txt is 200 and disallows all", + robots, + ); +} + docker([ "run", "-d", @@ -223,52 +275,55 @@ try { const site = (sites.body as { id: string; name: string }[])[0]; if (site?.name !== "Smoke Site") expect(false, "the scaffolded site is listed", sites.body); if (!site) throw new Error("no site"); - // A volume from 0.6.3 has a real /data/dist folder: the first build moves it aside, keeps it as + // RFC-0043 §1.10: `cloudhal build --published`, which site-build uses when the CLI has it. + const hasPublished = + spawnSync("docker", [ + "exec", + name, + "sh", + "-c", + "cloudhal build --help | grep -q -e --published", + ]).status === 0; + if (!hasPublished) + console.log("! this image's CLI has no `build --published`: skipping the status-page checks"); + const marker = () => + docker(["exec", name, "sh", "-c", `cat ${SITE}/.cloudhal-status 2>/dev/null || true`]); + + // The entrypoint's start-up build: on a new volume, the status page at once. + await waitStartupBuild(); + if (hasPublished) + expect(marker() === "not-published", "the start-up build is the status page", marker()); + step("the entrypoint's start-up site-build published the site's first build"); + + // A snapshot nobody published: `--published` must not build it. + const draft = await api("POST", `/api/sites/${site.id}/snapshots`, { + cookie, + body: { label: "smoke draft" }, + }); + expect(draft.status === 200 || draft.status === 201, "a draft snapshot", draft.body); + + // A volume from 0.6.3 has a real /data/dist folder: the next build moves it aside, keeps it as // site/previous, and puts the symlink in its place (#166). - docker(["exec", name, "sh", "-c", "mkdir -p /data/dist && touch /data/dist/marker"]); - // RFC-0043 §1.10: before the first publish the build is the status page and its marker, never - // the drafts, and Caddy answers it with 503 and Retry-After (robots.txt keeps its 200). + docker([ + "exec", + name, + "sh", + "-c", + "rm /data/dist && mkdir /data/dist && touch /data/dist/marker", + ]); + // Before the first publish the build is the status page and its marker, never the drafts, and + // Caddy answers it with 503 and Retry-After (robots.txt keeps its 200). const first = siteBuild("before the first publish"); const resolved = docker(["exec", name, "readlink", "-f", SITE]); expect(resolved.startsWith("/data/site/"), `${SITE} resolves under /data/site/`, resolved); const legacy = spawnSync("docker", ["exec", name, "test", "-e", "/data/site/previous/marker"]); expect(legacy.status === 0, "site/previous is the moved 0.6.3 folder, marker included"); - step("the first site-build moved a 0.6.3 /data/dist aside as site/previous"); - const marker = docker(["exec", name, "cat", `${SITE}/.cloudhal-status`]); - expect(marker === "not-published", "the unpublished build has the not-published marker", marker); - const statusHtml = docker(["exec", name, "cat", `${SITE}/index.html`]); - expect( - statusHtml.includes('data-state="not-published"') && !HOME_H1.test(statusHtml), - "the unpublished build is the status page, not the site", - ); + step("site-build moved a 0.6.3 /data/dist aside as site/previous"); await startCaddy(); - const unpublished = await served("/"); - expect( - unpublished.status === 503, - "Caddy answers / with 503 before the first publish", - unpublished.status, - ); - expect( - unpublished.headers.get("retry-after") === "3600", - "Retry-After: 3600", - unpublished.headers.get("retry-after"), - ); - expect( - unpublished.headers.get("cache-control") === "no-store", - "Cache-Control: no-store", - unpublished.headers.get("cache-control"), - ); - expect(unpublished.headers.get("x-robots-tag") === "noindex", "X-Robots-Tag: noindex"); - expect(unpublished.body.includes('data-state="not-published"'), "the 503 is the status page"); - const deep = await served("/posts/hello-world"); - expect(deep.status === 503, "every page is the 503 before the first publish", deep.status); - const robots = await served("/robots.txt"); - expect( - robots.status === 200 && robots.body.includes("Disallow: /"), - "robots.txt is 200 and disallows all", - robots, - ); - step("before the first publish Caddy serves the status page with 503 and Retry-After"); + if (hasPublished) { + await expectStatusPage(); + step("before the first publish, with a draft snapshot, Caddy serves the 503 status page"); + } const publish = await api("POST", `/api/sites/${site.id}/snapshots`, { cookie, @@ -279,8 +334,7 @@ try { const second = siteBuild("after the publish"); expect(second !== first, "the build after the publish is a new tree", { first, second }); - const noMarker = spawnSync("docker", ["exec", name, "test", "-e", `${SITE}/.cloudhal-status`]); - expect(noMarker.status !== 0, "the published build has no status marker"); + expect(marker() === "", "the published build has no status marker", marker()); expect( HOME_H1.test(docker(["exec", name, "cat", `${SITE}/index.html`])), "the built home page shows the site name",