Skip to content

feat(cli): rebuild the self-hosted site on publish (#150) - #199

Merged
Madraka merged 4 commits into
mainfrom
feat/self-host-rebuild-on-publish
Nov 10, 2026
Merged

Madraka merged 4 commits into
mainfrom
feat/self-host-rebuild-on-publish

Conversation

@Madraka

@Madraka Madraka commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Closes #150 (PR B of the #150 plan; PR A was #198).

With CLOUDHAL_SITE_BUILD=on (the Docker image's default) cloudhal studio runs the image's site-build --wait in a child process at start
and after every member of the onPublished family: the API publish route,
MCP publishes, approvals, rollbacks (the store's onPublished, now an
openLocalStore option) and RFC-0008 release runs (a new
ScheduledTickOptions.onReleasePublished, called after a committed
release's purge, in core-ts release-after.ts).

  • site-rebuild.ts is the queue. A burst builds once (3 s after the last
    publish, at most 60 s after the first). Two builds never overlap, and
    requests during a build give exactly one more. A failure is recorded,
    never retried in a loop; the next publish tries again.
  • site-build-run.ts runs the child, reads the rendered snapshot or the
    status page from cloudhal build --published's output with line
    scans, and writes a site.build audit row: system actor, duration,
    snapshot, the output tail on failure.
  • site-build --wait waits up to 30 min for the lock
    (/tmp/site-build.lock) that cron and hand-started runs take, so builds
    never overlap across processes. Studio runs the start-up build in its
    own queue. The entrypoint runs it only for a CLI whose help does not
    name CLOUDHAL_SITE_BUILD (older CLOUDHAL_VERSION backfills).
  • GET /api/sites/:id/site-build (site:read) reports the queue state and
    the last build. Studio's site overview shows a "Public site build"
    card (Kumo SectionCard, Badge, Banner, Code), and Activity labels the
    row "Build public site". The i18n keys were added in en and drafted
    for every other language through i18n:export/import (AI drafts
    awaiting review).
  • CLOUDHAL_SITE_BUILD=off turns the start-up build and the rebuild on
    publish off. A plain cloudhal studio defaults to off and stays off,
    with a warning, when no site-build is on PATH. Dockerfile, compose,
    .env.example and the self-host guide document it; cron becomes an
    optional fallback for publishes from other processes.

Tests:

  • site-rebuild.test.ts: debounce, max wait, serialisation, failure and
    retry, stop;
  • site-build-run.test.ts: the setting, PATH, output parsing (linear on
    100k characters), child failures, the audit row;
  • studio-site-build.test.ts: through the real Node publish path, with
    the start-up build, a burst, a rollback and a failed build retried;
  • testing-releases.ts: the release hook on SQLite and D1;
  • site-build-card.test.tsx: the Studio card;
  • docker-smoke.ts: Studio's start and publish builds, as reported by the
    API, and a failed build that keeps site/current.

Test plan

  • CI: lint, typecheck, vitest (Node 24 + 26), Rust, drift, CodeQL
  • docker-smoke: start-up build, publish build, failed build keeps site/current
  • Independent review (queue, lock, publish family, API scope, Studio card, i18n)

🤖 Generated with Claude Code

Madraka and others added 4 commits November 10, 2026 18:37
With CLOUDHAL_SITE_BUILD=on (the Docker image's default) `cloudhal
studio` runs the image's `site-build --wait` in a child process at start
and after every member of the onPublished family: the API publish route,
MCP publishes, approvals, rollbacks (the store's onPublished, now an
openLocalStore option) and RFC-0008 release runs (a new
ScheduledTickOptions.onReleasePublished, called after a committed
release's purge, in core-ts release-after.ts).

- site-rebuild.ts is the queue. A burst builds once (3 s after the last
  publish, at most 60 s after the first). Two builds never overlap, and
  requests during a build give exactly one more. A failure is recorded,
  never retried in a loop; the next publish tries again.
- site-build-run.ts runs the child, reads the rendered snapshot or the
  status page from `cloudhal build --published`'s output with line
  scans, and writes a `site.build` audit row: system actor, duration,
  snapshot, the output tail on failure.
- site-build --wait waits up to 30 min for the lock
  (/tmp/site-build.lock) that cron and hand-started runs take, so builds
  never overlap across processes. Studio runs the start-up build in its
  own queue. The entrypoint runs it only for a CLI whose help does not
  name CLOUDHAL_SITE_BUILD (older CLOUDHAL_VERSION backfills).
- GET /api/sites/:id/site-build (site:read) reports the queue state and
  the last build. Studio's site overview shows a "Public site build"
  card (Kumo SectionCard, Badge, Banner, Code), and Activity labels the
  row "Build public site". The i18n keys were added in en and drafted
  for every other language through i18n:export/import (AI drafts
  awaiting review).
- CLOUDHAL_SITE_BUILD=off turns the start-up build and the rebuild on
  publish off. A plain `cloudhal studio` defaults to off and stays off,
  with a warning, when no site-build is on PATH. Dockerfile, compose,
  .env.example and the self-host guide document it; cron becomes an
  optional fallback for publishes from other processes.

Tests:
- site-rebuild.test.ts: debounce, max wait, serialisation, failure and
  retry, stop;
- site-build-run.test.ts: the setting, PATH, output parsing (linear on
  100k characters), child failures, the audit row;
- studio-site-build.test.ts: through the real Node publish path, with
  the start-up build, a burst, a rollback and a failed build retried;
- testing-releases.ts: the release hook on SQLite and D1;
- site-build-card.test.tsx: the Studio card;
- docker-smoke.ts: Studio's start and publish builds, as reported by the
  API, and a failed build that keeps site/current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- Studio typecheck: useSchedulerStalled returns the status only when
  stalled, so the banner gets a narrowed SchedulerStatus.
- Playground: GET /api/sites/:id/site-build is in PLAYGROUND_ALLOW (it
  answers { enabled: false } there).
- "Publish now" on a release rebuilds: ReleaseServiceOptions and the
  API/MCP SchedulesDeps take onReleasePublished, passed into the
  executor publishNow builds; Studio wires it to the rebuild queue.
  (releaseExecutor moved to core-ts release-executor.ts and SchedulesDeps
  to mcp-server schedules-deps.ts, both re-exported, for the size ratchet.)
- Shutdown: Studio stops taking requests first, gives a running build
  5 s, then ends it; the build runs in its own process group
  (site-build-child.ts), so SIGTERM, then SIGKILL after 2 s, reaches
  cloudhal build under the shell too. The rebuilder passes an AbortSignal
  to run and records "stopped: Studio is shutting down" before the
  database closes; a second Ctrl-C or SIGTERM ends the build at once.
  site-build traps TERM/INT (exit non-zero, staging removed) and does not
  interrupt its publishing renames.
- Build deadline: CLOUDHAL_SITE_BUILD_TIMEOUT (seconds, default 1800)
  ends a hung build the same way and records "timed out after …".
- Entrypoint: an empty CLOUDHAL_SITE_BUILD is "on" and the normalised
  value is exported to cloudhal studio.
- What is served: site-build writes /data/site/current.json after each
  swap (at, build, state, snapshotId, source from
  CLOUDHAL_SITE_BUILD_SOURCE); SiteBuildService.served() reads it
  defensively (size limit, checked fields, must name site/current's build)
  and the route returns it. The card shows what visitors see from it,
  drops a failure a later served build superseded, and says "Nothing is
  online yet" when a failure follows no served build.
- The card stops polling when the feature is off or the request failed.
- Card tests wait for the query to settle; new tests for served, the
  banners, polling and hidden output.
- The failure's output tail is returned only to callers with audit:read,
  and is stored as plain text (ANSI and control characters stripped in
  one linear scan).
- Docs: self-host guide, docker README, .env.example, Dockerfile comment,
  changeset; docker-smoke checks current.json and the served report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- site-build ignores TERM and INT (trap '') for the swap, so the group
  signal from Studio or a terminal no longer kills its mv/ln children
  half-way (a 0.6.3 volume could lose /data/dist). The old legacy folder
  is removed in the preflight, the "live" line is printed once the swap is
  whole, and the prune of old builds runs after the traps are restored,
  interruptible and never a failure. The comments now say what happens.
- Studio reads the build id from that line and records it in the
  site.build audit row (input.build); served reports the build id too
  (current.json, else site/current's target). The card treats a failed
  run whose build id is the served one as live (built, no "previous
  version" banner), compares build ids before timestamps, and the log no
  longer says the previous site is still served for such a run.
- SiteBuildCard keeps polling after a network error, a 5xx, a 408 or a
  429 (at the last pace, idle without data); it stops only when the
  server says the feature is off or answers with another 4xx.
- Studio's stop closes idle connections at once, and after the site build
  is stopped gives open requests (a Build chat stream) the rest of an 8 s
  budget, then closes every connection, then the database.

Tests: parseLiveBuild (and a 100k-character case), a run that went live
then failed, the audit row's build, served with build ids, the card's
build-id cases and polling after a 500, and a stop with a request still
open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
- site-build writes /data/site/current.json from the shell with printf
  inside the protected swap (Node resets ignored signals, so a stop there
  could drop it). The snapshot id is read before the swap and kept only
  when it is letters, digits, _ and -; the build id is checked too, so
  the JSON needs no escaping. The header comment says so.
- runSiteBuildCommand reads the live build id while streaming, one
  reader per stream (stdout and stderr lines no longer mix), lines over
  4 KB dropped rather than buffered, the last line flushed at close;
  it no longer depends on the 16 KB tail.
- The open-request shutdown test is named for what it checks and also
  checks that nothing listens after stop.

Tests: a live line followed by more than 16 KB of output, and site-build
lines around one too long to buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit d3be2f6 into main Nov 10, 2026
11 checks passed
@Madraka
Madraka deleted the feat/self-host-rebuild-on-publish branch November 10, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-host: rebuild the static site automatically on publish (follow-up to #143)

1 participant