changeset 249:c5129452493e

[deploy] Bundle Mercurial with hg-web Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 04:16:45 -0700
parents b8b6e726964a
children 745fd127b2a1
files MODULE.bazel hg-web/BUILD hg-web/README.md hg-web/deploy.sh hg-web/dev.sh hg-web/e2e/app_e2e_test.js hg-web/main.c hg-web/nginx/hg-web.conf.template hg-web/repository_sync_test.sh hg-web/run.sh hg-web/systemd/hg-web.service.template third_party/mercurial/BUILD third_party/mercurial/README.md third_party/mercurial/hg.sh third_party/mercurial/runtime.bzl
diffstat 15 files changed, 656 insertions(+), 76 deletions(-) [+]
line wrap: on
line diff
--- a/MODULE.bazel	Tue Aug 04 02:44:06 2026 -0700
+++ b/MODULE.bazel	Tue Aug 04 04:16:45 2026 -0700
@@ -29,6 +29,12 @@
 """,
 )
 
+http_file(
+  name = "mercurial_cp311_wheel",
+  url = "https://files.pythonhosted.org/packages/ea/21/053406cf91b23bef2ac27a7023632efaf0d3c126134b322e2bb753626102/mercurial-7.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl",
+  sha256 = "33576d9747b7db7d40950cb122c5a87686d92ea1dfe0f1ef0498df077c852ba7",
+)
+
 http_archive(
   name = "playwright_chromium_linux",
   urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"],
--- a/hg-web/BUILD	Tue Aug 04 02:44:06 2026 -0700
+++ b/hg-web/BUILD	Tue Aug 04 04:16:45 2026 -0700
@@ -1,5 +1,6 @@
 load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
 load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
+load("@rules_shell//shell:sh_test.bzl", "sh_test")
 load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle")
 
 # Source files
@@ -53,12 +54,38 @@
   srcs = glob(["src/**"]) + [":compiled_js", ":public_files", ":public_fonts_files"],
 )
 
+genrule(
+  name = "run_hg_web_launcher",
+  srcs = ["run.sh"],
+  outs = ["run_hg_web"],
+  cmd = "cp $(location run.sh) $@ && chmod 0555 $@",
+)
+
+filegroup(
+  name = "mercurial_runtime_data",
+  srcs = select({
+    "//config:linux_x86_64": ["//third_party/mercurial:runtime"],
+    "//conditions:default": [],
+  }),
+)
+
+filegroup(
+  name = "runtime_files",
+  srcs = [
+    ":run_hg_web_launcher",
+    ":mercurial_runtime_data",
+  ],
+)
+
 # Server binaries
 cc_binary(
   name = "hg_web_server",
   srcs = ["main.c"],
   deps = ["//seobeo:seobeo"],
-  data = [":all_assets"],
+  data = [
+    ":all_assets",
+    ":runtime_files",
+  ],
   visibility = ["//hg-web:__subpackages__"],
 )
 
@@ -66,7 +93,10 @@
   name = "hg_web_server_debug",
   srcs = ["main.c"],
   deps = ["//seobeo:seobeo"],
-  data = [":all_assets"],
+  data = [
+    ":all_assets",
+    ":runtime_files",
+  ],
 )
 
 sh_binary(
@@ -74,13 +104,30 @@
   srcs = ["dev.sh"],
   data = [
     ":hg_web_server",
+    ":mercurial_runtime_data",
     "@bazel_tools//tools/bash/runfiles",
   ],
 )
 
 test_suite(
   name = "tests",
-  tests = ["//hg-web/e2e:app_e2e_test"],
+  tests = [
+    ":repository_sync_test",
+    "//hg-web/e2e:app_e2e_test",
+  ],
+)
+
+sh_test(
+  name = "repository_sync_test",
+  srcs = ["repository_sync_test.sh"],
+  data = [
+    "//third_party/mercurial:runtime",
+    "@bazel_tools//tools/bash/runfiles",
+  ],
+  target_compatible_with = [
+    "@platforms//cpu:x86_64",
+    "@platforms//os:linux",
+  ],
 )
 
 bundle(
@@ -93,6 +140,8 @@
   srcs = ["deploy.sh"],
   data = [
     ":hg_web_server_bundle",
+    "nginx/hg-web.conf.template",
+    "systemd/hg-web.service.template",
     "@bazel_tools//tools/bash/runfiles",
   ],
 )
--- a/hg-web/README.md	Tue Aug 04 02:44:06 2026 -0700
+++ b/hg-web/README.md	Tue Aug 04 04:16:45 2026 -0700
@@ -193,10 +193,12 @@
 ```
 
 This starts `hg serve` on `127.0.0.1:4444` and `hg_web_server` on port `6970`.
-Stopping the Bazel target stops both child processes.
+The `hg serve` process comes from `//third_party/mercurial:runtime` and points at
+the current workspace. Stopping the Bazel target stops both child processes.
 
-The bundle contains `hg_web_server` and `hg-web/src/`, including generated
-`page.js`, markdown WASM, highlight.js, styles, and image assets.
+The bundle contains `hg_web_server`, `hg-web/run_hg_web`, a pinned pure-Python
+Mercurial runtime, and `hg-web/src/`, including generated `page.js`, markdown
+WASM, highlight.js, styles, and image assets.
 
 ## Deployment contract
 
@@ -205,17 +207,52 @@
 ```text
 nginx
   -> hg_web_server.service
-     -> /opt/hg_web_server_bundle_active/hg_web_server
-     -> working directory: /opt/hg_web_server_bundle_active
-  -> hg serve service on 127.0.0.1:4444
+     -> /opt/hg_web_server/active/hg-web/run_hg_web
+        -> bundled Mercurial 7.2.3 serves
+           /opt/hg_web_server/repository on 127.0.0.1:4444
+        -> hg_web_server serves the forge on 127.0.0.1:6970
 ```
 
-`deploy.sh` builds an optimized bundle into a revisioned release directory,
-atomically repoints `/opt/hg_web_server_bundle_active`, restarts
-`hg_web_server.service`, and checks `http://127.0.0.1:6970/`. A failed restart
-or health check restores the previous release and restarts it. The service,
-release root, active path, health URL, user, and group can be overridden with
-environment variables.
+`deploy.sh` follows the same new/active bundle swap as the personal site. It
+builds the optimized bundle and maintains a canonical repository outside the
+release directories. The first deploy creates it with a locking-aware local
+clone. Later deploys create a Mercurial bundle from the workspace and pull that
+bundle into the persistent repository, preserving any commits pushed directly
+to production. The script verifies the repository, installs the systemd unit,
+swaps the active directory, restarts the combined service, and checks
+`http://127.0.0.1:6970/`. A failed restart restores the previous active
+directory.
+
+There is no separate production `hg serve` service and no dependency on the
+host's `hg` command. The pinned Mercurial wheel requires CPython 3.11. The
+systemd unit uses `KillMode=control-group`, so both the Mercurial and C child
+processes stop together.
+
+On its first run, deploy also installs `/etc/nginx/sites-available/hg-web` when
+Nginx uses Debian-style `sites-available`/`sites-enabled` directories. Existing
+site files are left untouched.
+
+```bash
+NGINX_SERVER_NAME=zenbu.babocoder.com bazel run //hg-web:deploy
+```
+
+The checked-in template listens on origin HTTP port 80, which works when TLS is
+terminated upstream. For direct origin TLS, provide a complete certificate-aware
+template:
+
+```bash
+NGINX_SITE_TEMPLATE=/etc/nginx/templates/hg-web-tls.conf \
+  bazel run //hg-web:deploy
+```
+
+Set `MANAGE_NGINX=false` to leave Nginx untouched. `NGINX_SITE_NAME`,
+`NGINX_SITE_AVAILABLE`, and `NGINX_SITE_ENABLED` can override the destination
+names and paths. `DEPLOY_ROOT` controls the validated deployment root;
+`REPOSITORY_PATH` can override the persistent canonical repository within it.
+
+Pushes are disabled by default. `HG_ALLOW_PUSH=true` enables unauthenticated
+pushes in the internal `hg serve`; only use it when `/repo` is protected by
+Nginx authentication or another trusted access layer.
 
 ## Publishing
 
--- a/hg-web/deploy.sh	Tue Aug 04 02:44:06 2026 -0700
+++ b/hg-web/deploy.sh	Tue Aug 04 04:16:45 2026 -0700
@@ -2,14 +2,63 @@
 set -Eeuo pipefail
 
 SERVICE_NAME="${SERVICE_NAME:-hg_web_server.service}"
-RELEASE_ROOT="${RELEASE_ROOT:-/opt/hg_web_server_releases}"
-ACTIVE_PATH="${ACTIVE_PATH:-/opt/hg_web_server_bundle_active}"
-HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}"
 SERVICE_USER="${SERVICE_USER:-hg_web_server}"
 SERVICE_GROUP="${SERVICE_GROUP:-zenbu_team}"
+DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/hg_web_server}"
+ACTIVE_PATH="${ACTIVE_PATH:-${DEPLOY_ROOT}/active}"
+NEW_PATH="${NEW_PATH:-${DEPLOY_ROOT}/new}"
+BACKUP_PATH="${BACKUP_PATH:-${DEPLOY_ROOT}/previous}"
+REPOSITORY_PATH="${REPOSITORY_PATH:-${DEPLOY_ROOT}/repository}"
+REPOSITORY_NEW_PATH="${REPOSITORY_NEW_PATH:-${DEPLOY_ROOT}/repository-new}"
+HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}"
+HG_ALLOW_PUSH="${HG_ALLOW_PUSH:-false}"
+MANAGE_NGINX="${MANAGE_NGINX:-auto}"
+NGINX_SERVER_NAME="${NGINX_SERVER_NAME:-zenbu.babocoder.com}"
+NGINX_SITE_NAME="${NGINX_SITE_NAME:-hg-web}"
+NGINX_SITE_AVAILABLE="${NGINX_SITE_AVAILABLE:-/etc/nginx/sites-available/${NGINX_SITE_NAME}}"
+NGINX_SITE_ENABLED="${NGINX_SITE_ENABLED:-/etc/nginx/sites-enabled/${NGINX_SITE_NAME}}"
 
-workspace="${BUILD_WORKSPACE_DIRECTORY:-$(hg root)}"
+workspace="${BUILD_WORKSPACE_DIRECTORY:-$(cd "$(dirname "$0")/.." && pwd)}"
 cd "$workspace"
+if [[ ! -d "$workspace/.hg" ]]; then
+  echo "Run deployment from a Zenbu Mercurial workspace." >&2
+  exit 1
+fi
+
+DEPLOY_ROOT="$(realpath -m "$DEPLOY_ROOT")"
+ACTIVE_PATH="$(realpath -m "$ACTIVE_PATH")"
+NEW_PATH="$(realpath -m "$NEW_PATH")"
+BACKUP_PATH="$(realpath -m "$BACKUP_PATH")"
+REPOSITORY_PATH="$(realpath -m "$REPOSITORY_PATH")"
+REPOSITORY_NEW_PATH="$(realpath -m "$REPOSITORY_NEW_PATH")"
+if [[ ! "$DEPLOY_ROOT" =~ ^/opt/[A-Za-z0-9._/-]+$ ]]; then
+  echo "DEPLOY_ROOT must be a dedicated directory below /opt." >&2
+  exit 1
+fi
+declare -A deployment_paths=()
+for path in \
+  "$ACTIVE_PATH" \
+  "$NEW_PATH" \
+  "$BACKUP_PATH" \
+  "$REPOSITORY_PATH" \
+  "$REPOSITORY_NEW_PATH"; do
+  if [[ "$path" != "$DEPLOY_ROOT/"* ]]; then
+    echo "Deployment path escapes DEPLOY_ROOT: $path" >&2
+    exit 1
+  fi
+  if [[ -n "${deployment_paths[$path]:-}" ]]; then
+    echo "Deployment paths must be distinct: $path" >&2
+    exit 1
+  fi
+  deployment_paths["$path"]=1
+done
+if [[ ! "$SERVICE_USER" =~ ^[A-Za-z0-9._-]+$ ||
+      ! "$SERVICE_GROUP" =~ ^[A-Za-z0-9._-]+$ ||
+      ! "$SERVICE_NAME" =~ ^[A-Za-z0-9._@-]+$ ||
+      ! "$HG_ALLOW_PUSH" =~ ^(true|false)$ ]]; then
+  echo "Invalid systemd deployment setting." >&2
+  exit 1
+fi
 
 if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then
   if [[ -n "${RUNFILES_DIR:-}" ]]; then
@@ -37,18 +86,36 @@
     exit 1
   fi
   bundle_dir="$(rlocation _main/hg-web/hg_web_server_bundle)"
+  nginx_template="$(rlocation _main/hg-web/nginx/hg-web.conf.template)"
+  service_template="$(rlocation _main/hg-web/systemd/hg-web.service.template)"
 else
   bazel build -c opt //hg-web:hg_web_server_bundle
   bundle_dir="bazel-bin/hg-web/hg_web_server_bundle"
+  nginx_template="hg-web/nginx/hg-web.conf.template"
+  service_template="hg-web/systemd/hg-web.service.template"
 fi
+NGINX_SITE_TEMPLATE="${NGINX_SITE_TEMPLATE:-$nginx_template}"
 
-revision="$(hg log -r . -T '{node|short}')"
-release_name="${revision}-$(date -u +%Y%m%dT%H%M%SZ)"
-release_dir="${RELEASE_ROOT}/${release_name}"
-staging_dir="${RELEASE_ROOT}/.${release_name}.tmp"
-next_link="${ACTIVE_PATH}.next"
-previous_release=""
-promoted=0
+rendered_nginx="$(mktemp)"
+rendered_service="$(mktemp)"
+service_backup="$(mktemp)"
+repository_bundle="$(mktemp)"
+release_id="$(date -u +%Y%m%dT%H%M%SZ)-$$"
+promotion_prepared=0
+service_changed=0
+service_had_previous=0
+service_was_enabled=0
+nginx_site_created=0
+nginx_link_created=0
+lock_acquired=0
+
+cleanup() {
+  rm -f \
+    "$rendered_nginx" \
+    "$rendered_service" \
+    "$service_backup" \
+    "$repository_bundle"
+}
 
 health_check() {
   for _ in $(seq 1 20); do
@@ -57,54 +124,198 @@
     fi
     sleep 1
   done
-  echo "Health check failed: $HEALTH_URL" >&2
   return 1
 }
 
-point_active_at() {
-  local target="$1"
-  sudo rm -f "$next_link"
-  sudo ln -s "$target" "$next_link"
-  sudo mv -Tf "$next_link" "$ACTIVE_PATH"
+restore_host_configuration() {
+  local restore_failed=0
+
+  if [[ "$nginx_link_created" -eq 1 ]]; then
+    sudo rm -f "$NGINX_SITE_ENABLED" || restore_failed=1
+  fi
+  if [[ "$nginx_site_created" -eq 1 ]]; then
+    sudo rm -f "$NGINX_SITE_AVAILABLE" || restore_failed=1
+  fi
+  if [[ "$nginx_link_created" -eq 1 || "$nginx_site_created" -eq 1 ]]; then
+    if sudo nginx -t; then
+      sudo systemctl reload nginx || restore_failed=1
+    else
+      restore_failed=1
+    fi
+  fi
+
+  if [[ "$service_changed" -eq 1 ]]; then
+    if [[ "$service_had_previous" -eq 1 ]]; then
+      sudo install -o root -g root -m 0644 \
+        "$service_backup" "/etc/systemd/system/$SERVICE_NAME" ||
+        restore_failed=1
+    else
+      sudo rm -f "/etc/systemd/system/$SERVICE_NAME" ||
+        restore_failed=1
+    fi
+    sudo systemctl daemon-reload || restore_failed=1
+    if [[ "$service_was_enabled" -eq 1 ]]; then
+      sudo systemctl enable "$SERVICE_NAME" >/dev/null ||
+        restore_failed=1
+    else
+      sudo systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
+    fi
+  fi
+
+  if [[ "$restore_failed" -ne 0 ]]; then
+    echo "Host configuration rollback needs manual attention." >&2
+  fi
+  return 0
 }
 
 rollback() {
-  trap - ERR
-  if [[ "$promoted" -eq 1 && -n "$previous_release" && -d "$previous_release" ]]; then
-    echo "Deployment failed; rolling back to $previous_release" >&2
-    point_active_at "$previous_release"
-    sudo systemctl restart "$SERVICE_NAME"
-    health_check || echo "Rollback completed, but the health check still fails." >&2
-  else
-    echo "Deployment failed and no previous release is available for rollback." >&2
+  trap - ERR INT TERM EXIT
+  if [[ "$lock_acquired" -eq 1 ]]; then
+    restore_host_configuration
+    if [[ "$promotion_prepared" -eq 1 ]]; then
+      if [[ -d "$BACKUP_PATH" ]]; then
+        sudo rm -rf "$ACTIVE_PATH"
+        sudo mv "$BACKUP_PATH" "$ACTIVE_PATH"
+        sudo systemctl restart "$SERVICE_NAME" || true
+      elif sudo test -f "$ACTIVE_PATH/.hg-web-release" &&
+           [[ "$(sudo cat "$ACTIVE_PATH/.hg-web-release")" == "$release_id" ]]; then
+        sudo rm -rf "$ACTIVE_PATH"
+        sudo systemctl stop "$SERVICE_NAME" || true
+      fi
+    fi
+    sudo rm -rf "$NEW_PATH"
+    sudo rm -rf "$REPOSITORY_NEW_PATH"
   fi
-  sudo rm -rf "$staging_dir"
+  cleanup
   exit 1
 }
-trap rollback ERR
+trap rollback ERR INT TERM
+trap cleanup EXIT
+
+deploy_user="$(id -un)"
+sudo install -d -o "$deploy_user" -g "$SERVICE_GROUP" -m 0755 "$DEPLOY_ROOT"
+exec 9>"$DEPLOY_ROOT/deploy.lock"
+if ! flock -n 9; then
+  echo "Another hg-web deployment is already running." >&2
+  exit 1
+fi
+lock_acquired=1
+sudo rm -rf "$NEW_PATH"
+sudo cp -a "$bundle_dir" "$NEW_PATH"
+sudo chown -R "$deploy_user:$SERVICE_GROUP" "$NEW_PATH"
+printf '%s\n' "$release_id" > "$NEW_PATH/.hg-web-release"
+
+bundled_hg="$NEW_PATH/third_party/mercurial/runtime/bin/hg"
+test -x "$NEW_PATH/hg_web_server"
+test -x "$NEW_PATH/hg-web/run_hg_web"
+test -x "$bundled_hg"
 
-sudo install -d -o root -g "$SERVICE_GROUP" -m 0755 "$RELEASE_ROOT"
-sudo rm -rf "$staging_dir"
-sudo install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$staging_dir"
-sudo cp -a "${bundle_dir}/." "$staging_dir/"
-sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$staging_dir"
+if [[ -e "$REPOSITORY_PATH" && ! -d "$REPOSITORY_PATH/.hg" ]]; then
+  echo "REPOSITORY_PATH exists but is not a Mercurial repository." >&2
+  exit 1
+fi
+if [[ ! -d "$REPOSITORY_PATH/.hg" ]]; then
+  sudo rm -rf "$REPOSITORY_NEW_PATH"
+  HGRCPATH= "$bundled_hg" clone \
+    --noupdate --pull "$workspace" "$REPOSITORY_NEW_PATH"
+  sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$REPOSITORY_NEW_PATH"
+  sudo mv "$REPOSITORY_NEW_PATH" "$REPOSITORY_PATH"
+else
+  HGRCPATH= "$bundled_hg" --repository "$workspace" \
+    bundle --all "$repository_bundle"
+  sudo chown "$deploy_user:$SERVICE_GROUP" "$repository_bundle"
+  chmod 0640 "$repository_bundle"
+  sudo -u "$SERVICE_USER" env HGRCPATH= \
+    "$bundled_hg" --repository "$REPOSITORY_PATH" \
+    pull "$repository_bundle"
+fi
+sudo -u "$SERVICE_USER" env HGRCPATH= \
+  "$bundled_hg" --repository "$REPOSITORY_PATH" verify
+
+sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$NEW_PATH"
 
-sudo test -x "$staging_dir/hg_web_server"
-sudo test -f "$staging_dir/hg-web/src/index.html"
-sudo test -f "$staging_dir/hg-web/src/page.js"
-sudo mv "$staging_dir" "$release_dir"
+sed \
+  -e "s|__SERVICE_USER__|${SERVICE_USER}|g" \
+  -e "s|__SERVICE_GROUP__|${SERVICE_GROUP}|g" \
+  -e "s|__ACTIVE_PATH__|${ACTIVE_PATH}|g" \
+  -e "s|__HG_ALLOW_PUSH__|${HG_ALLOW_PUSH}|g" \
+  -e "s|__REPOSITORY_PATH__|${REPOSITORY_PATH}|g" \
+  "$service_template" > "$rendered_service"
+if sudo test -f "/etc/systemd/system/$SERVICE_NAME"; then
+  sudo cat "/etc/systemd/system/$SERVICE_NAME" > "$service_backup"
+  chmod 0600 "$service_backup"
+  service_had_previous=1
+fi
+if sudo systemctl is-enabled --quiet "$SERVICE_NAME"; then
+  service_was_enabled=1
+fi
+service_changed=1
+sudo install -o root -g root -m 0644 \
+  "$rendered_service" "/etc/systemd/system/$SERVICE_NAME"
+sudo systemctl daemon-reload
 
-if [[ -L "$ACTIVE_PATH" ]]; then
-  previous_release="$(readlink -f "$ACTIVE_PATH")"
-elif [[ -d "$ACTIVE_PATH" ]]; then
-  previous_release="${RELEASE_ROOT}/legacy-$(date -u +%Y%m%dT%H%M%SZ)"
-  sudo mv "$ACTIVE_PATH" "$previous_release"
+manage_nginx=0
+case "$MANAGE_NGINX" in
+  1|true|yes) manage_nginx=1 ;;
+  0|false|no) ;;
+  auto)
+    if command -v nginx >/dev/null 2>&1 &&
+       [[ -d /etc/nginx/sites-available && -d /etc/nginx/sites-enabled ]]; then
+      manage_nginx=1
+    fi
+    ;;
+  *)
+    echo "MANAGE_NGINX must be auto, true, or false." >&2
+    exit 1
+    ;;
+esac
+
+if [[ "$manage_nginx" -eq 1 &&
+      ! -e "$NGINX_SITE_AVAILABLE" &&
+      ! -L "$NGINX_SITE_AVAILABLE" ]]; then
+  if [[ ! "$NGINX_SERVER_NAME" =~ ^[A-Za-z0-9.-]+$ ]]; then
+    echo "Invalid NGINX_SERVER_NAME: $NGINX_SERVER_NAME" >&2
+    exit 1
+  fi
+  if [[ ! -f "$NGINX_SITE_TEMPLATE" ]]; then
+    echo "Nginx template not found: $NGINX_SITE_TEMPLATE" >&2
+    exit 1
+  fi
+  sed "s/__SERVER_NAME__/${NGINX_SERVER_NAME}/g" \
+    "$NGINX_SITE_TEMPLATE" > "$rendered_nginx"
+  sudo install -D -o root -g root -m 0644 \
+    "$rendered_nginx" "$NGINX_SITE_AVAILABLE"
+  nginx_site_created=1
+  sudo ln -s "$NGINX_SITE_AVAILABLE" "$NGINX_SITE_ENABLED"
+  nginx_link_created=1
+  sudo nginx -t
+  sudo systemctl reload nginx
+elif [[ "$manage_nginx" -eq 1 &&
+        ! -e "$NGINX_SITE_ENABLED" &&
+        ! -L "$NGINX_SITE_ENABLED" ]]; then
+  sudo ln -s "$NGINX_SITE_AVAILABLE" "$NGINX_SITE_ENABLED"
+  nginx_link_created=1
+  sudo nginx -t
+  sudo systemctl reload nginx
+elif [[ "$manage_nginx" -eq 1 ]]; then
+  echo "Nginx site already exists; leaving it unchanged."
 fi
 
-point_active_at "$release_dir"
-promoted=1
+sudo rm -rf "$BACKUP_PATH"
+promotion_prepared=1
+if [[ -e "$ACTIVE_PATH" || -L "$ACTIVE_PATH" ]]; then
+  sudo mv "$ACTIVE_PATH" "$BACKUP_PATH"
+fi
+sudo mv "$NEW_PATH" "$ACTIVE_PATH"
+
+sudo systemctl enable "$SERVICE_NAME" >/dev/null
 sudo systemctl restart "$SERVICE_NAME"
-health_check
+if ! health_check; then
+  echo "hg-web health check failed." >&2
+  rollback
+fi
 
-trap - ERR
-echo "Deployment complete: $release_dir"
+sudo rm -rf "$BACKUP_PATH"
+trap - ERR INT TERM EXIT
+cleanup
+echo "Deployment complete: $ACTIVE_PATH"
--- a/hg-web/dev.sh	Tue Aug 04 02:44:06 2026 -0700
+++ b/hg-web/dev.sh	Tue Aug 04 04:16:45 2026 -0700
@@ -33,6 +33,7 @@
 fi
 
 server="$(rlocation _main/hg-web/hg_web_server)"
+hg_binary="$(rlocation _main/third_party/mercurial/runtime/bin/hg)"
 runfiles_workspace="$(dirname "$(dirname "$server")")"
 hg_pid=""
 server_pid=""
@@ -54,7 +55,10 @@
 trap cleanup EXIT
 trap 'exit 130' INT TERM
 
-hg --repository "$workspace" serve \
+HGRCPATH= "$hg_binary" \
+  --config "web.allow-push=" \
+  --config "web.deny-push=*" \
+  --repository "$workspace" serve \
   --address 127.0.0.1 \
   --port 4444 \
   --accesslog - \
--- a/hg-web/e2e/app_e2e_test.js	Tue Aug 04 02:44:06 2026 -0700
+++ b/hg-web/e2e/app_e2e_test.js	Tue Aug 04 04:16:45 2026 -0700
@@ -8,6 +8,7 @@
 const BASE_URL = 'http://127.0.0.1:6970';
 const RUNFILES = process.env.JS_BINARY__RUNFILES;
 const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
+let hgCommand = 'hg';
 
 function run(command, args, options = {}) {
   const result = spawnSync(command, args, {
@@ -64,7 +65,7 @@
 
 function createFixtureRepository(root) {
   const options = { env: mercurialEnvironment(root) };
-  run('hg', ['init', root], options);
+  run(hgCommand, ['init', root], options);
   fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
   fs.mkdirSync(path.join(root, 'src'), { recursive: true });
   fs.writeFileSync(
@@ -88,18 +89,18 @@
   fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3'));
   fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n'));
   fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3]));
-  run('hg', ['--repository', root, 'add'], options);
-  run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options);
-  const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
+  run(hgCommand, ['--repository', root, 'add'], options);
+  run(hgCommand, ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options);
+  const firstNode = run(hgCommand, ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
 
   fs.writeFileSync(
     path.join(root, 'README.md'),
     '# Updated Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n\nSecond revision.\n',
   );
   fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n');
-  run('hg', ['add', 'new-file.txt'], { ...options, cwd: root });
-  run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options);
-  const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
+  run(hgCommand, ['add', 'new-file.txt'], { ...options, cwd: root });
+  run(hgCommand, ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options);
+  const tipNode = run(hgCommand, ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
   return { firstNode, tipNode };
 }
 
@@ -147,16 +148,25 @@
   let browser;
 
   try {
-    const { firstNode, tipNode } = createFixtureRepository(fixtureRoot);
     const runfilesWorkspace = path.join(RUNFILES, WORKSPACE);
     const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server');
+    hgCommand = path.join(
+      runfilesWorkspace,
+      'third_party',
+      'mercurial',
+      'runtime',
+      'bin',
+      'hg',
+    );
     const chromiumPath = path.resolve(process.env.CHROMIUM_PATH);
 
     assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`);
+    assert.ok(fs.existsSync(hgCommand), `missing bundled Mercurial: ${hgCommand}`);
     assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`);
+    const { firstNode, tipNode } = createFixtureRepository(fixtureRoot);
 
     hgServer = spawn(
-      'hg',
+      hgCommand,
       [
         '--repository', fixtureRoot,
         'serve',
@@ -260,7 +270,7 @@
     assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404);
 
     const identify = run(
-      'hg',
+      hgCommand,
       ['identify', `${BASE_URL}/repo`],
       { env: mercurialEnvironment(fixtureRoot) },
     );
--- a/hg-web/main.c	Tue Aug 04 02:44:06 2026 -0700
+++ b/hg-web/main.c	Tue Aug 04 04:16:45 2026 -0700
@@ -13,7 +13,7 @@
 #define HG_SERVE_HOST "127.0.0.1"
 #define HG_SERVE_PORT "4444"
 #define HG_API_TIMEOUT_MS 15000
-#define HG_STREAM_IDLE_TIMEOUT_MS 60000
+#define HG_STREAM_IDLE_TIMEOUT_MS 3600000
 #define MAX_PATH_LENGTH 4096
 #define MAX_WIRE_QUERY_LENGTH 8192
 #define MAX_WIRE_HEADER_LENGTH 8192
@@ -501,11 +501,11 @@
       arena);
 }
 
-static int64_t monotonic_milliseconds(void)
+static int64 monotonic_milliseconds(void)
 {
   struct timespec now;
   clock_gettime(CLOCK_MONOTONIC, &now);
-  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+  return (int64)now.tv_sec * 1000 + now.tv_nsec / 1000000;
 }
 
 static size_t find_http_header_length(const uint8 *buffer, size_t length)
@@ -664,7 +664,7 @@
   }
 
   boolean response_started = FALSE;
-  int64_t last_progress = monotonic_milliseconds();
+  int64 last_progress = monotonic_milliseconds();
   while (!response_started)
   {
     int read_result = Seobeo_Handle_Read(upstream);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/nginx/hg-web.conf.template	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,34 @@
+server {
+    listen 80;
+    listen [::]:80;
+    server_name __SERVER_NAME__;
+
+    client_max_body_size 1g;
+
+    location = /repo {
+        proxy_pass http://127.0.0.1:6970;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Connection "";
+
+        proxy_request_buffering off;
+        proxy_buffering off;
+        proxy_cache off;
+        proxy_read_timeout 3600s;
+        proxy_send_timeout 3600s;
+    }
+
+    location / {
+        proxy_pass http://127.0.0.1:6970;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Connection "";
+        proxy_read_timeout 30s;
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/repository_sync_test.sh	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+if [[ -n "${RUNFILES_DIR:-}" ]]; then
+  source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash"
+elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then
+  runfiles_library="$(
+    grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \
+      "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2-
+  )"
+  source "$runfiles_library"
+else
+  echo "Bazel runfiles are unavailable." >&2
+  exit 1
+fi
+
+hg_binary="$(rlocation _main/third_party/mercurial/runtime/bin/hg)"
+root="${TEST_TMPDIR:?}/repository-sync"
+source_repository="$root/source"
+production_repository="$root/production"
+bundle="$root/workspace.hg"
+mkdir -p "$root"
+
+HGRCPATH= "$hg_binary" init "$source_repository"
+printf 'base\n' > "$source_repository/file.txt"
+HGRCPATH= "$hg_binary" --cwd "$source_repository" add file.txt
+HGRCPATH= "$hg_binary" --cwd "$source_repository" \
+  commit -u test -m base
+
+HGRCPATH= "$hg_binary" clone \
+  "$source_repository" "$production_repository"
+
+printf 'workspace\n' >> "$source_repository/file.txt"
+HGRCPATH= "$hg_binary" --cwd "$source_repository" \
+  commit -u test -m workspace
+
+printf 'production\n' >> "$production_repository/file.txt"
+HGRCPATH= "$hg_binary" --cwd "$production_repository" \
+  commit -u test -m production
+
+HGRCPATH= "$hg_binary" --repository "$source_repository" \
+  bundle --all "$bundle"
+HGRCPATH= "$hg_binary" --repository "$production_repository" \
+  pull "$bundle"
+
+descriptions="$(
+  HGRCPATH= "$hg_binary" --repository "$production_repository" \
+    log -T '{desc}\n'
+)"
+grep -qx workspace <<<"$descriptions"
+grep -qx production <<<"$descriptions"
+[[ "$(
+  HGRCPATH= "$hg_binary" --repository "$production_repository" \
+    heads -T '.'
+)" == ".." ]]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/run.sh	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+bundle_root="$(dirname "$script_dir")"
+repository="${HG_REPOSITORY_PATH:-$bundle_root/repository}"
+hg_binary="$bundle_root/third_party/mercurial/runtime/bin/hg"
+server="$bundle_root/hg_web_server"
+hg_pid=""
+server_pid=""
+
+if [[ ! -x "$hg_binary" ]]; then
+  echo "Bundled Mercurial is unavailable: $hg_binary" >&2
+  exit 1
+fi
+if [[ ! -x "$server" ]]; then
+  echo "hg-web server is unavailable: $server" >&2
+  exit 1
+fi
+if [[ ! -d "$repository/.hg" ]]; then
+  echo "Mercurial repository snapshot is unavailable: $repository" >&2
+  exit 1
+fi
+
+cleanup() {
+  trap - EXIT INT TERM
+  for pid in "$server_pid" "$hg_pid"; do
+    if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
+      kill "$pid" 2>/dev/null || true
+    fi
+  done
+  for pid in "$server_pid" "$hg_pid"; do
+    if [[ -n "$pid" ]]; then
+      wait "$pid" 2>/dev/null || true
+    fi
+  done
+}
+trap cleanup EXIT
+trap 'exit 130' INT TERM
+
+hg_arguments=(
+  --repository "$repository"
+  serve
+  --address 127.0.0.1
+  --port 4444
+  --accesslog -
+  --errorlog -
+)
+if [[ "${HG_ALLOW_PUSH:-false}" == "true" ]]; then
+  echo "WARNING: Mercurial pushes are enabled; protect /repo in Nginx."
+  hg_arguments=(
+    --config "web.allow-push=*"
+    --config "web.deny-push="
+    --config "web.push_ssl=false"
+    "${hg_arguments[@]}"
+  )
+else
+  hg_arguments=(
+    --config "web.allow-push="
+    --config "web.deny-push=*"
+    "${hg_arguments[@]}"
+  )
+fi
+
+export HGRCPATH=
+"$hg_binary" "${hg_arguments[@]}" &
+hg_pid=$!
+
+(
+  cd "$bundle_root"
+  exec "$server"
+) &
+server_pid=$!
+
+echo "hg-web: http://127.0.0.1:6970"
+echo "hg serve repository: $repository"
+
+set +e
+wait -n "$hg_pid" "$server_pid"
+status=$?
+set -e
+exit "$status"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/systemd/hg-web.service.template	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,21 @@
+[Unit]
+Description=Zenbu Mercurial forge
+After=network.target
+
+[Service]
+Type=simple
+User=__SERVICE_USER__
+Group=__SERVICE_GROUP__
+WorkingDirectory=__ACTIVE_PATH__
+ExecStart=__ACTIVE_PATH__/hg-web/run_hg_web
+Environment=HG_ALLOW_PUSH=__HG_ALLOW_PUSH__
+Environment=HG_REPOSITORY_PATH=__REPOSITORY_PATH__
+Restart=always
+RestartSec=3
+KillMode=control-group
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+
+[Install]
+WantedBy=multi-user.target
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/mercurial/BUILD	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,12 @@
+load(":runtime.bzl", "mercurial_runtime")
+
+mercurial_runtime(
+    name = "runtime",
+    archive = "@mercurial_cp311_wheel//file",
+    launcher = "hg.sh",
+    target_compatible_with = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:linux",
+    ],
+    visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/mercurial/README.md	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,10 @@
+# Mercurial
+
+`//third_party/mercurial:runtime` assembles the SHA-pinned Mercurial 7.2.3
+CPython 3.11 manylinux wheel. It includes the supported C and zstd extensions
+required by Zenbu's repository format. Production requires CPython 3.11 but
+does not depend on a host-installed `hg` command. This prebuilt runtime targets
+x86-64 Linux.
+
+hg-web includes this runtime in its deployment bundle. Its production launcher
+uses the bundled command for `hg serve`.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/mercurial/hg.sh	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -eu
+
+runtime_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
+python_binary="${PYTHON_BIN:-python3.11}"
+if ! command -v "$python_binary" >/dev/null 2>&1; then
+  echo "Mercurial requires CPython 3.11: $python_binary was not found." >&2
+  exit 1
+fi
+if [ "$("$python_binary" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" != "3.11" ]; then
+  echo "Mercurial requires CPython 3.11." >&2
+  exit 1
+fi
+export PYTHONPATH="$runtime_root${PYTHONPATH:+:$PYTHONPATH}"
+exec "$python_binary" -m mercurial "$@"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/mercurial/runtime.bzl	Tue Aug 04 04:16:45 2026 -0700
@@ -0,0 +1,34 @@
+def _mercurial_runtime_impl(ctx):
+    output = ctx.actions.declare_directory(ctx.label.name)
+    ctx.actions.run_shell(
+        inputs = [ctx.file.archive, ctx.file.launcher],
+        outputs = [output],
+        command = """
+set -e
+mkdir -p {output}/bin
+unzip -q {archive} -d {output}
+cp {output}/mercurial-7.2.3.dist-info/licenses/COPYING {output}/COPYING
+cp {launcher} {output}/bin/hg
+chmod 0555 {output}/bin/hg
+chmod -R a-w {output}/hgdemandimport {output}/hgext {output}/mercurial
+""".format(
+            archive = ctx.file.archive.path,
+            launcher = ctx.file.launcher.path,
+            output = output.path,
+        ),
+        progress_message = "Assembling pure-Python Mercurial runtime",
+    )
+    return [
+        DefaultInfo(
+            files = depset([output]),
+            runfiles = ctx.runfiles(files = [output]),
+        ),
+    ]
+
+mercurial_runtime = rule(
+    implementation = _mercurial_runtime_impl,
+    attrs = {
+        "archive": attr.label(allow_single_file = True, mandatory = True),
+        "launcher": attr.label(allow_single_file = True, mandatory = True),
+    },
+)