Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/build-published-status.md
Original file line number Diff line number Diff line change
@@ -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. 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.
5 changes: 4 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 26 additions & 1 deletion deploy/docker/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion deploy/docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 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`):
Expand All @@ -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.
23 changes: 23 additions & 0 deletions deploy/docker/bin/entrypoint
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions deploy/docker/bin/site-build
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@
# 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). 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
Expand Down Expand Up @@ -121,7 +126,14 @@ for entry in .[!.]* ..?* *; do
*) cp -a "$entry" "$stage/" ;;
esac
done
(cd "$stage" && cloudhal build "$@")
# `--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/<build id>, then each pointer with one rename.
id="$(date -u +%Y%m%dT%H%M%SZ).$$"
Expand All @@ -146,4 +158,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
Loading
Loading