Mercurial
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), + }, +)