From e08ab2b67ec76c690ffc1dfbc0894b2e49fd7ab7 Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Sun, 13 Sep 2026 07:33:42 +0000 Subject: [PATCH 1/2] feat: vacuum database before serving --- .github/workflows/ci.yml | 113 ++++++++++++- .opencode_web_yolo.Dockerfile | 1 + .opencode_web_yolo.sh | 4 +- .opencode_web_yolo_entrypoint.sh | 23 +++ CHANGELOG.md | 6 + README.md | 20 ++- TECHNICAL.md | 12 +- VERSION | 2 +- .../references/test-acceptance-matrix.md | 5 +- skills/opencode-web-runtime/SKILL.md | 9 +- .../references/flag-contracts.md | 4 +- .../references/runtime-checklist.md | 4 +- tests/run.sh | 1 + tests/test_dry_run.sh | 3 +- tests/test_entrypoint_instruction_flag.sh | 2 +- tests/test_health.sh | 1 + tests/test_startup_vacuum.sh | 155 ++++++++++++++++++ 17 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 tests/test_startup_vacuum.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b71227..88e1c72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: bash -n tests/test_entrypoint_auth_guard.sh bash -n tests/test_entrypoint_instruction_flag.sh bash -n tests/test_entrypoint_home_pin.sh + bash -n tests/test_startup_vacuum.sh bash -n tests/test_docs_apache_endpoints.sh bash -n tests/test_help.sh bash -n tests/test_playwright_dockerfile_contract.sh @@ -58,6 +59,7 @@ jobs: shellcheck -x tests/test_entrypoint_auth_guard.sh shellcheck -x tests/test_entrypoint_instruction_flag.sh shellcheck -x tests/test_entrypoint_home_pin.sh + shellcheck -x tests/test_startup_vacuum.sh shellcheck -x tests/test_docs_apache_endpoints.sh shellcheck -x tests/test_help.sh shellcheck -x tests/test_playwright_dockerfile_contract.sh @@ -106,7 +108,116 @@ jobs: run: docker build -f .opencode_web_yolo.Dockerfile -t opencode_web_yolo:ci . - name: Validate required binaries run: | - docker run --rm --entrypoint sh opencode_web_yolo:ci -lc 'command -v gh && command -v git && command -v ssh && command -v tini' + docker run --rm --entrypoint sh opencode_web_yolo:ci -lc 'command -v gh && command -v git && command -v ssh && command -v sqlite3 && command -v timeout && command -v tini && opencode serve --help' + - name: Validate real startup VACUUM + run: | + set -euo pipefail + data_dir="$(mktemp -d)" + trap 'rm -rf "$data_dir"' EXIT + mkdir -p "${data_dir}/opencode" + + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "${data_dir}:/data" \ + --entrypoint sqlite3 \ + opencode_web_yolo:ci /data/opencode/opencode.db \ + 'CREATE TABLE retained (id INTEGER PRIMARY KEY, value TEXT); + CREATE TABLE bulky (payload BLOB); + INSERT INTO retained(value) VALUES (zeroblob(4)); + WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM seq WHERE i < 2000) + INSERT INTO bulky(payload) SELECT zeroblob(4096) FROM seq; + DELETE FROM bulky;' + + before_freelist="$(docker run --rm \ + -v "${data_dir}:/data" \ + --entrypoint sqlite3 \ + opencode_web_yolo:ci /data/opencode/opencode.db 'PRAGMA freelist_count;')" + test "${before_freelist}" -gt 0 + + vacuum_output="$(docker run --rm \ + -e LOCAL_UID="$(id -u)" \ + -e LOCAL_GID="$(id -g)" \ + -e LOCAL_USER=ci \ + -e OPENCODE_SERVER_PASSWORD=ci-secret \ + -e OPENCODE_WEB_RETENTION_DAYS=0 \ + -e XDG_DATA_HOME=/data \ + -v "${data_dir}:/data" \ + --entrypoint /usr/local/bin/opencode_web_yolo_entrypoint.sh \ + opencode_web_yolo:ci true 2>&1)" + case "${vacuum_output}" in + *"VACUUM: compacting OpenCode database at /data/opencode/opencode.db."*) ;; + *) printf '%s\n' "${vacuum_output}" >&2; exit 1 ;; + esac + case "${vacuum_output}" in + *WARNING*) printf '%s\n' "${vacuum_output}" >&2; exit 1 ;; + esac + + retained_rows="$(docker run --rm \ + -v "${data_dir}:/data" \ + --entrypoint sqlite3 \ + opencode_web_yolo:ci /data/opencode/opencode.db 'SELECT count(*) FROM retained;')" + after_freelist="$(docker run --rm \ + -v "${data_dir}:/data" \ + --entrypoint sqlite3 \ + opencode_web_yolo:ci /data/opencode/opencode.db 'PRAGMA freelist_count;')" + test "${retained_rows}" = 1 + test "${after_freelist}" = 0 + - name: Smoke test authenticated opencode serve + run: | + set -euo pipefail + smoke_dir="$(mktemp -d)" + container_name=opencode_web_yolo_ci_smoke + host_port=4097 + cleanup() { + local status=$? + trap - EXIT + if [ "${status}" -ne 0 ]; then + docker logs "${container_name}" || true + fi + docker rm -f "${container_name}" >/dev/null 2>&1 || true + rm -rf "${smoke_dir}" + exit "${status}" + } + trap cleanup EXIT + mkdir -p "${smoke_dir}/config" "${smoke_dir}/data" + + docker run -d \ + --name "${container_name}" \ + --restart unless-stopped \ + -p "127.0.0.1:${host_port}:${host_port}" \ + -e LOCAL_UID="$(id -u)" \ + -e LOCAL_GID="$(id -g)" \ + -e LOCAL_USER=ci \ + -e OPENCODE_SERVER_PASSWORD=ci-secret \ + -e OPENCODE_SERVER_USERNAME=opencode \ + -e OPENCODE_WEB_PORT="${host_port}" \ + -e HOME=/home/opencode \ + -e XDG_CONFIG_HOME=/home/opencode/.config \ + -e XDG_DATA_HOME=/home/opencode/.local/share \ + -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state \ + -v "${smoke_dir}/config:/home/opencode/.config/opencode" \ + -v "${smoke_dir}/data:/home/opencode/.local/share/opencode" \ + opencode_web_yolo:ci \ + opencode serve --hostname 0.0.0.0 --port "${host_port}" >/dev/null + + healthy=0 + unauthenticated_status='' + health_body='' + for attempt in $(seq 1 60); do + if unauthenticated_status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 "http://127.0.0.1:${host_port}/global/health" 2>/dev/null)" \ + && [ "${unauthenticated_status}" = 401 ] \ + && health_body="$(curl -fsS --max-time 2 -u 'opencode:ci-secret' "http://127.0.0.1:${host_port}/global/health" 2>/dev/null)" \ + && node -e 'const value = JSON.parse(process.argv[1]); process.exit(value && value.healthy === true ? 0 : 1)' "${health_body}"; then + healthy=1 + break + fi + if [ "$(docker inspect --format '{{.State.Running}}' "${container_name}" 2>/dev/null || true)" != true ]; then + break + fi + sleep 1 + done + test "${unauthenticated_status}" = 401 + test "${healthy}" = 1 - name: Build Playwright-enabled runtime image run: | docker build \ diff --git a/.opencode_web_yolo.Dockerfile b/.opencode_web_yolo.Dockerfile index 0239e9b..5c061ed 100644 --- a/.opencode_web_yolo.Dockerfile +++ b/.opencode_web_yolo.Dockerfile @@ -17,6 +17,7 @@ RUN apt-get update \ gosu \ openssh-client \ passwd \ + sqlite3 \ sudo \ tini \ && rm -rf /var/lib/apt/lists/* diff --git a/.opencode_web_yolo.sh b/.opencode_web_yolo.sh index b9cc96e..7fb5729 100755 --- a/.opencode_web_yolo.sh +++ b/.opencode_web_yolo.sh @@ -925,7 +925,7 @@ main() { host_agents_log="Host instruction file mount disabled by --no-host-agents." fi - app_cmd=(opencode web --hostname "${OPENCODE_WEB_HOSTNAME}" --port "${OPENCODE_WEB_PORT}") + app_cmd=(opencode serve --hostname "${OPENCODE_WEB_HOSTNAME}" --port "${OPENCODE_WEB_PORT}") app_cmd+=("${passthrough[@]}") ensure_image @@ -958,7 +958,7 @@ main() { printf '%s\n' "runtime_env_xdg_config_home=${runtime_xdg_config}" printf '%s\n' "runtime_env_xdg_data_home=${runtime_xdg_data}" printf '%s\n' "runtime_env_xdg_state_home=${runtime_xdg_state}" - printf '%s\n' "command=opencode web --hostname ${OPENCODE_WEB_HOSTNAME} --port ${OPENCODE_WEB_PORT}" + printf '%s\n' "command=opencode serve --hostname ${OPENCODE_WEB_HOSTNAME} --port ${OPENCODE_WEB_PORT}" printf '%s\n' "env.OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}" printf '%s\n' "host_agents_source=${host_agents_source}" printf '%s\n' "host_agents_path=${host_agents_path}" diff --git a/.opencode_web_yolo_entrypoint.sh b/.opencode_web_yolo_entrypoint.sh index e553c07..e0e71b5 100755 --- a/.opencode_web_yolo_entrypoint.sh +++ b/.opencode_web_yolo_entrypoint.sh @@ -10,6 +10,9 @@ XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${OPENCODE_WEB_YOLO_HOME}/.config}" XDG_DATA_HOME="${XDG_DATA_HOME:-${OPENCODE_WEB_YOLO_HOME}/.local/share}" XDG_STATE_HOME="${XDG_STATE_HOME:-${XDG_DATA_HOME}/opencode/state}" OPENCODE_WEB_RETENTION_DAYS="${OPENCODE_WEB_RETENTION_DAYS-0}" +STARTUP_VACUUM_BUSY_TIMEOUT_MS=5000 +STARTUP_VACUUM_TERM_TIMEOUT_SECONDS=300 +STARTUP_VACUUM_KILL_AFTER_SECONDS=5 case "$OPENCODE_WEB_RETENTION_DAYS" in ''|*[!0-9]*) @@ -80,6 +83,26 @@ export XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" export XDG_DATA_HOME="${XDG_DATA_HOME}" export XDG_STATE_HOME="${XDG_STATE_HOME}" +opencode_database="${XDG_DATA_HOME}/opencode/opencode.db" +if [ -f "${opencode_database}" ]; then + printf '%s\n' "[opencode_web_yolo] VACUUM: compacting OpenCode database at ${opencode_database}." + if gosu "${runtime_user}" timeout \ + --kill-after="${STARTUP_VACUUM_KILL_AFTER_SECONDS}" \ + "${STARTUP_VACUUM_TERM_TIMEOUT_SECONDS}" \ + sqlite3 \ + -cmd ".timeout ${STARTUP_VACUUM_BUSY_TIMEOUT_MS}" \ + "${opencode_database}" 'VACUUM;'; then + : + else + vacuum_status=$? + if [ "$vacuum_status" -eq 124 ] || [ "$vacuum_status" -eq 137 ]; then + printf '%s\n' "[opencode_web_yolo] WARNING: startup VACUUM timed out after the ${STARTUP_VACUUM_TERM_TIMEOUT_SECONDS}-second TERM deadline (KILL escalation after ${STARTUP_VACUUM_KILL_AFTER_SECONDS} additional seconds); continuing startup." >&2 + else + printf '%s\n' "[opencode_web_yolo] WARNING: startup VACUUM failed for ${opencode_database}; continuing startup." >&2 + fi + fi +fi + if [ "$OPENCODE_WEB_RETENTION_DAYS" = "0" ]; then exec env HOME="${HOME}" XDG_CONFIG_HOME="${XDG_CONFIG_HOME}" XDG_DATA_HOME="${XDG_DATA_HOME}" XDG_STATE_HOME="${XDG_STATE_HOME}" gosu "${runtime_user}" "$@" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index aad78bd..0ace6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented here. +## [0.3.0] - 2026-09-13 + +- Switched runtime launches from `opencode web` to `opencode serve`. +- Added startup SQLite VACUUM maintenance for existing OpenCode databases, with mapped-user execution, a 5000 ms lock wait, TERM after 300 seconds, KILL escalation after 5 more seconds, and warning-only failure handling. +- Added `sqlite3` to the runtime image. + ## [0.2.1] - 2026-09-01 - Hardened retention against cross-directory active descendants, stale root refreshes, stale DELETE responses, unsupported OpenCode versions, stalled APIs, unsafe pagination boundaries, signal/process-tree issues, and scheduler misconfiguration. Deletion verification now requires direct 404/not-found responses, and documentation explicitly records the API's residual delete-if-idle race. diff --git a/README.md b/README.md index 082a29f..fbbc2bc 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ opencode_web_yolo Defaults: - Port: `4096` - Bind/publish: `127.0.0.1:4096:4096` -- OpenCode web host inside container: `0.0.0.0` +- OpenCode serve host inside container: `0.0.0.0` - OpenCode package install target: `latest` at build time - Playwright build: disabled by default; opt in with `OPENCODE_WEB_BUILD_PLAYWRIGHT=1` in the persistent config or `--playwright` for one run - Container name: `opencode_web_yolo` @@ -99,8 +99,8 @@ Operator-facing settings: | --- | --- | --- | | `OPENCODE_SERVER_PASSWORD` | none, required | Required non-empty password for OpenCode Web. Startup fails if it is missing or empty. | | `OPENCODE_SERVER_USERNAME` | `opencode` | Login username paired with `OPENCODE_SERVER_PASSWORD`. | -| `OPENCODE_WEB_PORT` | `4096` | Host/container port used for `opencode web` and the local Docker publish mapping. | -| `OPENCODE_WEB_HOSTNAME` | `0.0.0.0` | Hostname passed to `opencode web` inside the container. | +| `OPENCODE_WEB_PORT` | `4096` | Host/container port used for `opencode serve` and the local Docker publish mapping. | +| `OPENCODE_WEB_HOSTNAME` | `0.0.0.0` | Hostname passed to `opencode serve` inside the container. | | `OPENCODE_WEB_CONTAINER_NAME` | `opencode_web_yolo` | Docker container name used for launch, replacement, and diagnostics. | | `OPENCODE_WEB_RESTART_POLICY` | `unless-stopped` | Docker restart policy applied to the container. | | `OPENCODE_WEB_RUN_DETACHED` | `1` | Launch mode default. Use `1` for background mode or `0` for attached runs unless overridden by flags. | @@ -144,6 +144,18 @@ Truthy toggle values such as `true`, `yes`, and `on` are accepted and normalized Provider auth/session state (for example OpenAI and GitHub Copilot links) persists across restarts from the OpenCode data path. The wrapper also pins runtime env (`HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`) to `/home/opencode` paths so app writes always land on mounted host directories. +On every container startup, after the mapped-user ownership and XDG setup, the entrypoint checks +`$XDG_DATA_HOME/opencode/opencode.db`. If that database exists, it runs `VACUUM;` with `sqlite3` as +the mapped runtime user, waiting up to 5000 ms for a lock. GNU `timeout` sends TERM after 300 +seconds and sends KILL 5 seconds later if VACUUM is still running. A missing database is skipped +without creating one. Vacuum can add startup latency and temporarily require additional disk space +while SQLite rewrites the database. If it cannot vacuum because of a lock, permissions, corruption, +disk space, timeout, or another error, startup prints a warning and continues. + +Startup VACUUM is separate from weekly retention. The retention worker remains an authenticated +OpenCode API worker: it does not use raw SQL or manually modify SQLite WAL, SHM, or journal +sidecars. + ## Weekly session retention Enable cleanup in the generated config, or override it for one invocation: @@ -210,7 +222,7 @@ Run in background (with automatic startup on reboot): ```bash export OPENCODE_SERVER_PASSWORD='change-me-now' -mkdir -p "$HOME/.config/opencode" "$HOME/.local/share/opencode" && (docker rm -f opencode_web_yolo >/dev/null 2>&1 || true) && docker run -d --name opencode_web_yolo --restart unless-stopped -p 127.0.0.1:4096:4096 -e LOCAL_UID="$(id -u)" -e LOCAL_GID="$(id -g)" -e LOCAL_USER="$(id -un)" -e OPENCODE_SERVER_PASSWORD -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state -v "$PWD:/workspace" -v "$HOME/.config/opencode:/home/opencode/.config/opencode" -v "$HOME/.local/share/opencode:/home/opencode/.local/share/opencode" opencode_web_yolo:latest opencode web --hostname 0.0.0.0 --port 4096 +mkdir -p "$HOME/.config/opencode" "$HOME/.local/share/opencode" && (docker rm -f opencode_web_yolo >/dev/null 2>&1 || true) && docker run -d --name opencode_web_yolo --restart unless-stopped -p 127.0.0.1:4096:4096 -e LOCAL_UID="$(id -u)" -e LOCAL_GID="$(id -g)" -e LOCAL_USER="$(id -un)" -e OPENCODE_SERVER_PASSWORD -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_STATE_HOME=/home/opencode/.local/share/opencode/state -v "$PWD:/workspace" -v "$HOME/.config/opencode:/home/opencode/.config/opencode" -v "$HOME/.local/share/opencode:/home/opencode/.local/share/opencode" opencode_web_yolo:latest opencode serve --hostname 0.0.0.0 --port 4096 ``` Force-refresh image to the resolved latest OpenCode and Playwright versions: diff --git a/TECHNICAL.md b/TECHNICAL.md index 09eb590..be362bc 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -4,7 +4,7 @@ - Host command: `opencode_web_yolo` - Wrapper builds/validates runtime image and runs: - - `opencode web --hostname 0.0.0.0 --port ${OPENCODE_WEB_PORT}` + - `opencode serve --hostname 0.0.0.0 --port ${OPENCODE_WEB_PORT}` - Docker publish contract: - `-p 127.0.0.1:${OPENCODE_WEB_PORT}:${OPENCODE_WEB_PORT}` - Container lifecycle defaults: @@ -73,6 +73,8 @@ Docker image includes: - `gh` - `git` - `openssh-client` +- `sqlite3` +- Debian coreutils `timeout` - runtime helpers (`gosu`, `sudo`, `passwd`, `ca-certificates`) - PID 1 init/subreaper (`tini`) - OpenCode CLI (`opencode-ai` npm package by default) @@ -100,6 +102,7 @@ Entrypoint behavior: - avoids recursive ownership operations across read-only mount boundaries. - installs passwordless sudo policy for mapped user. - executes command via `gosu`. +- after ownership and HOME/XDG setup, checks `${XDG_DATA_HOME}/opencode/opencode.db`; when present, runs `VACUUM;` through `sqlite3` via `gosu` as the mapped user with a 5000 ms busy timeout. GNU `timeout` sends TERM after 300 seconds and KILL 5 seconds later if the command remains alive. Missing databases are skipped without creation. Vacuum failures or timeout expiry warn to stderr and do not block either direct or retention-supervised OpenCode launch. This startup maintenance is separate from retention, whose worker never uses raw SQL or mutates SQLite WAL/SHM files. - when retention is enabled, starts OpenCode, waits for authenticated `/global/health`, and supervises a mapped-user scheduler; TERM/INT are forwarded and the app exit status is returned. - Docker starts `tini -s -g` so orphaned descendants are reaped and TERM/INT are forwarded to the child process group. The supervisor preserves the received signal when forwarding it to the app. - does not inject unsupported OpenCode CLI flags for instruction loading. @@ -171,14 +174,14 @@ Controls: - Update `VERSION` and `CHANGELOG.md` together. - Run `bash tests/run.sh`. - Run `bash -n` and `shellcheck` for touched shell scripts. -- Build the runtime image and verify required binaries (`gh`, `git`, `ssh`). +- Build the runtime image and verify required binaries (`gh`, `git`, `ssh`, `sqlite3`) plus `opencode serve --help`. - Verify README/TECHNICAL accuracy for any behavior changes. ## Test and CI Strategy Tests and CI assert: - `bash -n` and `shellcheck` on touched shell scripts. -- dry-run output contract (local-only port mapping, opencode web command, env values, config/data mounts, lifecycle flags, detach/pull defaults). +- dry-run output contract (local-only port mapping, `opencode serve` command, env values, config/data mounts, lifecycle flags, detach/pull defaults). - launch behavior replaces same-name containers by stopping running instances, then removing the old container before re-run. - password gate behavior when `OPENCODE_SERVER_PASSWORD` is missing. - `-gh` validation/mount behavior and `--mount-ssh` explicit warning/mount behavior. @@ -187,5 +190,6 @@ Tests and CI assert: - health output includes browser-vs-server persistence scope visibility. - retention configuration, marker path, API schedule, and dry-run state. - retention API compatibility, active-session skipping, serial deletion, marker retry semantics, and supervisor signal/exit behavior. -- Docker image build and runtime binary presence (`gh`, `git`, `ssh`). +- Docker image build and runtime binary presence (`gh`, `git`, `ssh`, `sqlite3`), including a successful `opencode serve --help` check. +- startup VACUUM behavior for existing and missing databases, custom XDG data paths, mapped-user invocation, SQLite timeout plus TERM/KILL escalation, warning-only failures, and continued application execution. - `VERSION` semver format and runtime-file/version drift guard. diff --git a/VERSION b/VERSION index 0c62199..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.3.0 diff --git a/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md b/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md index ed780e2..75e3927 100644 --- a/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md +++ b/skills/opencode-web-quality-docs/references/test-acceptance-matrix.md @@ -7,13 +7,13 @@ Use this matrix when authoring tests under `tests/`. - Shell syntax validation (`bash -n`) for wrapper scripts. - Dry-run output includes: - local-only port mapping - - OpenCode web command + - `opencode serve` command - expected env variables - OpenCode config mount - OpenCode data mount - explicit `HOME`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME` env contract when user mapping is enabled - Password enforcement fails when `OPENCODE_SERVER_PASSWORD` is missing/empty. -- Image contains `gh`, `git`, `ssh` binaries. +- Image contains `gh`, `git`, `ssh`, `sqlite3`, and `timeout` binaries; `opencode serve --help` succeeds. - `-gh` validates host `gh` install/auth and applies gh mount behavior. - `--mount-ssh` warns and mounts only on explicit request. - `--wrangler` requires an existing host `.wrangler` directory, warns about read-write Cloudflare credential exposure, mounts the exact `:rw` path only when requested, and remains absent by default. @@ -22,6 +22,7 @@ Use this matrix when authoring tests under `tests/`. - Persistence assertions verify state files are written to mounted host path, not only any in-container path. - Docs contract check ensures Apache stream endpoints include `/event` and `/global/event`, and excludes stale `/session/event`. - Health/diagnostics command reports key prerequisites and failures clearly. +- Startup VACUUM tests cover existing and missing databases, custom XDG data paths, mapped-user execution, the 5000 ms SQLite busy timeout, TERM at 300 seconds with KILL escalation 5 seconds later, warning-and-continue failures, and no manual WAL/SHM/journal sidecar mutation. - Retention accepts both flag forms, honors config/flag precedence, rejects invalid values, and propagates dry-run state. - Retention tests cover cutoff/pagination/root filtering, active-session skips, serial deletion, API incompatibility/failure, marker success/failure/retry, and scheduler signal/exit behavior. - Retention tests cover complete cross-directory hierarchy mapping, active descendants/unknown IDs, stale root refreshes, status changes before deletion, DELETE-true-but-still-present direct-GET verification, request timeout, supported-version gating, future markers, invalid poll intervals, exact query/cursor use, and malformed responses. diff --git a/skills/opencode-web-runtime/SKILL.md b/skills/opencode-web-runtime/SKILL.md index 8780a2b..62bf32d 100644 --- a/skills/opencode-web-runtime/SKILL.md +++ b/skills/opencode-web-runtime/SKILL.md @@ -25,8 +25,8 @@ Load only the file that matches the active task: Enforce these contracts on every runtime change: - Require `OPENCODE_SERVER_PASSWORD`; fail fast when empty or unset. - Publish local-only by default: `-p 127.0.0.1:${OPENCODE_WEB_PORT}:${OPENCODE_WEB_PORT}`. -- Run OpenCode Web with host `0.0.0.0` and configured port unless explicitly overridden. -- Install and expose `gh`, `git`, and SSH client binaries inside the image. +- Run `opencode serve` with host `0.0.0.0` and configured port unless explicitly overridden. +- Install and expose `gh`, `git`, SSH client binaries, and `sqlite3` inside the image. - Persist both OpenCode config and state directories across restarts. - Show explicit warnings before enabling sensitive mounts (`-gh`, `--mount-ssh`). - Keep entrypoint ownership setup compatible with read-only sensitive mounts. @@ -43,6 +43,7 @@ Enforce these contracts on every runtime change: 4. Emit identical run args for normal run and dry-run previews. 5. Keep diagnostics independent of container startup. 6. In entrypoint, map UID/GID, ensure writable runtime dirs, avoid recursive chown on read-only mounts, then exec via `gosu`. +7. After ownership and HOME/XDG exports, inspect `${XDG_DATA_HOME}/opencode/opencode.db`. If present, run startup `VACUUM;` via `sqlite3` and `gosu` as the mapped user with a 5000 ms busy timeout; GNU `timeout` sends TERM after 300 seconds and KILL 5 seconds later if needed. Skip missing databases without creating them and warn/continue on failures or timeout expiry. This is separate from retention's no-raw-SQL worker guarantee. # Retention lifecycle @@ -62,8 +63,8 @@ Enforce these contracts on every runtime change: # Done Criteria Consider runtime work complete only when: -- Password checks, local-only port publishing, and OpenCode web launch command match the plan. +- Password checks, local-only port publishing, and `opencode serve` launch command match the plan. - `-gh` verifies host `gh` presence and auth status before mounting. - `--mount-ssh` mounts read-only and warns clearly. -- OpenCode state persists across restarts without re-authentication churn. +- OpenCode state persists across restarts without re-authentication churn, and existing databases receive best-effort startup VACUUM maintenance with TERM/KILL timeout escalation. - Dry-run and diagnostics reflect actual runtime behavior. diff --git a/skills/opencode-web-runtime/references/flag-contracts.md b/skills/opencode-web-runtime/references/flag-contracts.md index d84b357..02de621 100644 --- a/skills/opencode-web-runtime/references/flag-contracts.md +++ b/skills/opencode-web-runtime/references/flag-contracts.md @@ -19,7 +19,7 @@ Use this file when adding or changing wrapper CLI behavior. ## Parsing Rules - Recognize wrapper flags first. -- Preserve pass-through args for OpenCode web command. +- Preserve pass-through args for the OpenCode serve command. - Require an explicit separator strategy so wrapper flags do not leak into app args. - Keep command behavior deterministic for both dry-run and run paths. - `--wrangler` must set `OPENCODE_WEB_BUILD_WRANGLER=1`, require the host @@ -31,7 +31,7 @@ Use this file when adding or changing wrapper CLI behavior. - Dry-run output includes: - local-only port mapping - - OpenCode web command + - `opencode serve` command - effective environment values - Unknown flags are either passed through or rejected intentionally with clear messaging. - Instruction loading must not add unsupported app CLI flags; rely on OpenCode native project/global rules discovery and mounted config paths. diff --git a/skills/opencode-web-runtime/references/runtime-checklist.md b/skills/opencode-web-runtime/references/runtime-checklist.md index 3a564cd..74c12be 100644 --- a/skills/opencode-web-runtime/references/runtime-checklist.md +++ b/skills/opencode-web-runtime/references/runtime-checklist.md @@ -28,11 +28,13 @@ Use this checklist for runtime changes in `.opencode_web_yolo.sh`, `.opencode_we - If runtime process user differs from image default user, explicitly pin `HOME`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME` to mounted persistence paths. - Avoid recursive ownership operations on paths that can contain read-only mounts. - Use `gosu` handoff for final command execution. -- Run OpenCode web with configured host and port. +- Run `opencode serve` with configured host and port. +- Provide `sqlite3` and Debian coreutils `timeout` in the image and, after mapped-user ownership plus HOME/XDG exports, best-effort VACUUM an existing `${XDG_DATA_HOME}/opencode/opencode.db` as the mapped user via `gosu` with a 5000 ms busy timeout. GNU `timeout` must send TERM after 300 seconds and KILL 5 seconds later if needed. Missing databases must not be created; failures or timeout expiry warn and do not block direct or retention-supervised launch. - Preserve provider/auth state across restart by mounting host OpenCode data directory. - Keep Playwright opt-in: `OPENCODE_WEB_BUILD_PLAYWRIGHT=1` in the persistent config is durable, while `--playwright` is one-shot. - Retention is opt-in with non-negative `OPENCODE_WEB_RETENTION_DAYS`; its scheduler starts only after authenticated health, runs as the mapped user, and persists a success marker under `XDG_STATE_HOME`. - Retention must use authenticated complete `/experimental/session` pagination, `/session/status` for every involved directory, direct `/session/:id` refresh/verification, and serial `DELETE /session/:id` calls; validate compatibility and fail closed without raw SQL/WAL/SHM mutation. +- Startup VACUUM is separate from retention: retention remains an API-only, no-raw-SQL worker and must not manually touch SQLite WAL/SHM/journal sidecars. - Map every listed session to a root across directories; block a mapped root when status reports a busy/retrying descendant, fail closed on unmapped active IDs or malformed hierarchies, refresh/recheck immediately before each delete, verify direct 404 before marker advancement, and reject unsafe equal-timestamp page boundaries. The API has no atomic delete-if-idle guarantee. - Validate positive worker fetch and scheduler poll timeouts; use `tini -s -g` for PID1 subreaping/group signal forwarding and preserve SIGINT semantics. - When enabled, install global `@playwright/test` at an explicit version, run its `playwright install --with-deps chromium`, and use `PLAYWRIGHT_BROWSERS_PATH=/ms-playwright`. diff --git a/tests/run.sh b/tests/run.sh index d093de2..b34e468 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -15,6 +15,7 @@ bash "${ROOT_DIR}/tests/test_health.sh" bash "${ROOT_DIR}/tests/test_entrypoint_auth_guard.sh" bash "${ROOT_DIR}/tests/test_entrypoint_home_pin.sh" bash "${ROOT_DIR}/tests/test_entrypoint_instruction_flag.sh" +bash "${ROOT_DIR}/tests/test_startup_vacuum.sh" bash "${ROOT_DIR}/tests/test_docs_apache_endpoints.sh" bash "${ROOT_DIR}/tests/test_help.sh" bash "${ROOT_DIR}/tests/test_playwright_dockerfile_contract.sh" diff --git a/tests/test_dry_run.sh b/tests/test_dry_run.sh index e7141ec..9689e8c 100755 --- a/tests/test_dry_run.sh +++ b/tests/test_dry_run.sh @@ -25,7 +25,8 @@ output="$("${ROOT_DIR}/.opencode_web_yolo.sh" -- --model local 2>&1)" assert_contains "$output" "DRY RUN" assert_contains "$output" "publish=127.0.0.1:4096:4096" -assert_contains "$output" "command=opencode web --hostname 0.0.0.0 --port 4096" +assert_contains "$output" "command=opencode serve --hostname 0.0.0.0 --port 4096" +assert_not_contains "$output" "opencode web" assert_contains "$output" "host_agents_source=none" assert_contains "$output" "host_agents_disabled=0" assert_contains "$output" "env.OPENCODE_SERVER_USERNAME=opencode" diff --git a/tests/test_entrypoint_instruction_flag.sh b/tests/test_entrypoint_instruction_flag.sh index d37e781..75be017 100644 --- a/tests/test_entrypoint_instruction_flag.sh +++ b/tests/test_entrypoint_instruction_flag.sh @@ -7,7 +7,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" entrypoint="$(cat "${ROOT_DIR}/.opencode_web_yolo_entrypoint.sh")" -assert_not_contains "$entrypoint" "set -- opencode web --instructions" +assert_not_contains "$entrypoint" "set -- opencode serve --instructions" assert_not_contains "$entrypoint" "--instructions \"\${INSTRUCTIONS}\"" printf '%s\n' "PASS: entrypoint does not inject unsupported instruction flags" diff --git a/tests/test_health.sh b/tests/test_health.sh index eb83896..52f3e40 100755 --- a/tests/test_health.sh +++ b/tests/test_health.sh @@ -15,6 +15,7 @@ setup_fake_docker "$FAKE_BIN" "$WRAPPER_VERSION" export PATH="${FAKE_BIN}:${PATH}" export HOME="${TMP_DIR}/home" unset XDG_CONFIG_HOME XDG_DATA_HOME || true +unset OPENCODE_WEB_RETENTION_DAYS || true mkdir -p "${HOME}" export OPENCODE_WEB_SKIP_UPDATE_CHECK=1 diff --git a/tests/test_startup_vacuum.sh b/tests/test_startup_vacuum.sh new file mode 100644 index 0000000..e36fae4 --- /dev/null +++ b/tests/test_startup_vacuum.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=tests/test_helpers.sh +. "${ROOT_DIR}/tests/test_helpers.sh" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT +FAKE_BIN="${TMP_DIR}/bin" +SQLITE_LOG="${TMP_DIR}/sqlite.log" +GOSU_LOG="${TMP_DIR}/gosu.log" +ENTRYPOINT_COPY="${TMP_DIR}/entrypoint.sh" +mkdir -p "$FAKE_BIN" + +entrypoint_source="$(<"${ROOT_DIR}/.opencode_web_yolo_entrypoint.sh")" +fixed_sudoers_path=/etc/sudoers.d/90-opencode-web-yolo +assert_contains "$entrypoint_source" "$fixed_sudoers_path" +# Keep production's fixed root-owned path; redirect only this temporary test copy. +entrypoint_source="${entrypoint_source//"${fixed_sudoers_path}"/"${TMP_DIR}/sudoers"}" +printf '%s\n' "$entrypoint_source" >"$ENTRYPOINT_COPY" +chmod +x "$ENTRYPOINT_COPY" + +cat >"${FAKE_BIN}/gosu" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +user="$1" +shift +printf 'user=%s command=%s\n' "$user" "$*" >>"${OPENCODE_WEB_TEST_GOSU_LOG}" +"$@" +EOF +chmod +x "${FAKE_BIN}/gosu" + +cat >"${FAKE_BIN}/timeout" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +kill_after="$1" +test "$kill_after" = "--kill-after=5" +shift +timeout_seconds="$1" +shift +printf 'timeout=%s command=%s\n' "$timeout_seconds" "$*" >>"${OPENCODE_WEB_TEST_GOSU_LOG}" +if [ "${OPENCODE_WEB_TEST_TIMEOUT_EXIT:-0}" = "124" ]; then + exit 124 +fi +"$@" +EOF +chmod +x "${FAKE_BIN}/timeout" + +cat >"${FAKE_BIN}/mkdir" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +last_arg="${!#}" +if [ "$last_arg" = "/workspace" ]; then + exit 0 +fi +exec /usr/bin/mkdir "$@" +EOF +chmod +x "${FAKE_BIN}/mkdir" + +cat >"${FAKE_BIN}/chown" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exit 0 +EOF +chmod +x "${FAKE_BIN}/chown" + +cat >"${FAKE_BIN}/sqlite3" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${OPENCODE_WEB_TEST_SQLITE_LOG}" +if [ "${OPENCODE_WEB_TEST_SQLITE_FAIL:-0}" = "1" ]; then + exit 1 +fi +EOF +chmod +x "${FAKE_BIN}/sqlite3" + +export PATH="${FAKE_BIN}:${PATH}" +export OPENCODE_SERVER_PASSWORD=secret +export OPENCODE_WEB_TEST_GOSU_LOG="$GOSU_LOG" +export OPENCODE_WEB_TEST_SQLITE_LOG="$SQLITE_LOG" +export LOCAL_UID="$(id -u)" +export LOCAL_GID="$(id -g)" +export LOCAL_USER="$(id -un)" + +run_entrypoint() { + local home="$1" data_home="$2" + shift 2 + mkdir -p "$home" "$data_home" + rm -f "${TMP_DIR}/sudoers" + : >"$GOSU_LOG" + : >"$SQLITE_LOG" + OPENCODE_WEB_YOLO_HOME="$home" \ + XDG_CONFIG_HOME="${home}/config" \ + XDG_DATA_HOME="$data_home" \ + XDG_STATE_HOME="${data_home}/opencode/state" \ + OPENCODE_WEB_RETENTION_DAYS=0 \ + "$ENTRYPOINT_COPY" "$@" +} + +existing_home="${TMP_DIR}/existing-home" +existing_data="${TMP_DIR}/custom-xdg-data" +existing_db="${existing_data}/opencode/opencode.db" +mkdir -p "$(dirname "$existing_db")" +: >"$existing_db" +existing_output="$(run_entrypoint "$existing_home" "$existing_data" true 2>&1)" +assert_contains "$existing_output" "VACUUM: compacting OpenCode database at ${existing_db}" +assert_contains "$(cat "$GOSU_LOG")" "user=${LOCAL_USER} command=timeout --kill-after=5 300 sqlite3 -cmd .timeout 5000 ${existing_db} VACUUM;" +assert_contains "$(cat "$SQLITE_LOG")" "-cmd .timeout 5000 ${existing_db} VACUUM;" + +missing_home="${TMP_DIR}/missing-home" +missing_data="${TMP_DIR}/missing-data" +missing_output="$(run_entrypoint "$missing_home" "$missing_data" touch "${TMP_DIR}/app-ran" 2>&1)" +assert_not_contains "$missing_output" "VACUUM:" +assert_not_contains "$(cat "$GOSU_LOG")" "sqlite3" +if [ -e "${missing_data}/opencode/opencode.db" ]; then + fail "missing database must not be created" +fi +if [ ! -e "${TMP_DIR}/app-ran" ]; then + fail "application did not execute when the database was missing" +fi + +failure_home="${TMP_DIR}/failure-home" +failure_data="${TMP_DIR}/failure-data" +failure_db="${failure_data}/opencode/opencode.db" +mkdir -p "$(dirname "$failure_db")" +: >"$failure_db" +set +e +failure_output="$(OPENCODE_WEB_TEST_SQLITE_FAIL=1 run_entrypoint "$failure_home" "$failure_data" touch "${TMP_DIR}/app-ran-after-failure" 2>&1)" +failure_status=$? +set -e +assert_equals "0" "$failure_status" +assert_contains "$failure_output" "WARNING: startup VACUUM failed for ${failure_db}; continuing startup." +assert_contains "$(cat "$GOSU_LOG")" "user=${LOCAL_USER} command=timeout --kill-after=5 300 sqlite3 -cmd .timeout 5000 ${failure_db} VACUUM;" +if [ ! -e "${TMP_DIR}/app-ran-after-failure" ]; then + fail "application did not continue after startup VACUUM failure" +fi + +timeout_home="${TMP_DIR}/timeout-home" +timeout_data="${TMP_DIR}/timeout-data" +timeout_db="${timeout_data}/opencode/opencode.db" +mkdir -p "$(dirname "$timeout_db")" +: >"$timeout_db" +set +e +timeout_output="$(OPENCODE_WEB_TEST_TIMEOUT_EXIT=124 run_entrypoint "$timeout_home" "$timeout_data" touch "${TMP_DIR}/app-ran-after-timeout" 2>&1)" +timeout_status=$? +set -e +assert_equals "0" "$timeout_status" +assert_contains "$timeout_output" "WARNING: startup VACUUM timed out after the 300-second TERM deadline (KILL escalation after 5 additional seconds); continuing startup." +assert_contains "$(cat "$GOSU_LOG")" "user=${LOCAL_USER} command=timeout --kill-after=5 300 sqlite3 -cmd .timeout 5000 ${timeout_db} VACUUM;" +if [ ! -e "${TMP_DIR}/app-ran-after-timeout" ]; then + fail "application did not continue after startup VACUUM timeout" +fi + +printf '%s\n' "PASS: startup VACUUM behavior" From 4ed953dfa82b09e7021ebab4f8d1c111e9fefe2a Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Sun, 13 Sep 2026 13:50:26 +0000 Subject: [PATCH 2/2] fix: restore PR quality gates --- .github/workflows/ci.yml | 4 +- tests/test_startup_vacuum.sh | 7 ++- tests/test_version_guard.sh | 112 +++++++++++++++++++++++++---------- tests/version_guard.sh | 55 ++++++++--------- 4 files changed, 115 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39d4c3a..141a0c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,6 +251,8 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 2 + fetch-depth: 0 - name: Validate version format and drift guard + env: + VERSION_GUARD_BASE_REF: ${{ github.event.pull_request.base.sha }} run: bash tests/version_guard.sh diff --git a/tests/test_startup_vacuum.sh b/tests/test_startup_vacuum.sh index e36fae4..1e79f42 100644 --- a/tests/test_startup_vacuum.sh +++ b/tests/test_startup_vacuum.sh @@ -79,9 +79,10 @@ export PATH="${FAKE_BIN}:${PATH}" export OPENCODE_SERVER_PASSWORD=secret export OPENCODE_WEB_TEST_GOSU_LOG="$GOSU_LOG" export OPENCODE_WEB_TEST_SQLITE_LOG="$SQLITE_LOG" -export LOCAL_UID="$(id -u)" -export LOCAL_GID="$(id -g)" -export LOCAL_USER="$(id -un)" +LOCAL_UID="$(id -u)" +LOCAL_GID="$(id -g)" +LOCAL_USER="$(id -un)" +export LOCAL_UID LOCAL_GID LOCAL_USER run_entrypoint() { local home="$1" data_home="$2" diff --git a/tests/test_version_guard.sh b/tests/test_version_guard.sh index 8f656e3..4e8acd6 100644 --- a/tests/test_version_guard.sh +++ b/tests/test_version_guard.sh @@ -27,50 +27,98 @@ commit_all() { git -C "$repo_dir" commit -q -m "$message" } +root_repo="${TMP_DIR}/root" +init_repo "$root_repo" +printf '%s\n' '0.2.1' >"${root_repo}/VERSION" +printf '%s\n' 'runtime' >"${root_repo}/.opencode_web_yolo.sh" +commit_all "$root_repo" "root" +root_output="$("${root_repo}/tests/version_guard.sh" 2>&1)" +assert_contains "$root_output" "No parent commit found; skipping runtime-file/version drift check." + normal_repo="${TMP_DIR}/normal" init_repo "$normal_repo" printf '%s\n' '0.2.1' >"${normal_repo}/VERSION" printf '%s\n' 'runtime before' >"${normal_repo}/.opencode_web_yolo.sh" commit_all "$normal_repo" "base" printf '%s\n' 'runtime after' >"${normal_repo}/.opencode_web_yolo.sh" -commit_all "$normal_repo" "runtime change" +git -C "$normal_repo" add . +git -C "$normal_repo" commit -q -m $'runtime change\n\nparent this-is-commit-message-content' -if normal_output="$(${normal_repo}/tests/version_guard.sh 2>&1)"; then +if normal_output="$("${normal_repo}/tests/version_guard.sh" 2>&1)"; then fail "normal runtime change without VERSION bump must fail" fi assert_contains "$normal_output" "Runtime/release files changed but VERSION was not updated." -merge_repo="${TMP_DIR}/merge" -init_repo "$merge_repo" -printf '%s\n' '0.2.1' >"${merge_repo}/VERSION" -printf '%s\n' 'runtime before' >"${merge_repo}/.opencode_web_yolo.sh" -printf '%s\n' 'installer before' >"${merge_repo}/install.sh" -commit_all "$merge_repo" "base" -base_commit="$(git -C "$merge_repo" rev-parse HEAD)" - -git -C "$merge_repo" checkout -q -b release -printf '%s\n' '0.3.0' >"${merge_repo}/VERSION" -commit_all "$merge_repo" "aggregate release version" - -git -C "$merge_repo" checkout -q -b runtime "$base_commit" -printf '%s\n' '0.2.2' >"${merge_repo}/VERSION" -printf '%s\n' 'installer after' >"${merge_repo}/install.sh" -commit_all "$merge_repo" "runtime release changes" - -git -C "$merge_repo" checkout -q release -if git -C "$merge_repo" merge --no-ff runtime -m "merge release changes" >/dev/null 2>&1; then - fail "test topology must require VERSION conflict resolution" -fi -printf '%s\n' '0.3.0' >"${merge_repo}/VERSION" -git -C "$merge_repo" add VERSION -git -C "$merge_repo" commit -q -m "merge release changes" +positive_repo="${TMP_DIR}/positive-merge" +init_repo "$positive_repo" +printf '%s\n' '0.2.1' >"${positive_repo}/VERSION" +printf '%s\n' 'runtime before' >"${positive_repo}/.opencode_web_yolo.sh" +commit_all "$positive_repo" "base" +positive_base_ref="$(git -C "$positive_repo" rev-parse HEAD)" + +git -C "$positive_repo" checkout -q -b pr "$positive_base_ref" +printf '%s\n' '0.3.0' >"${positive_repo}/VERSION" +printf '%s\n' 'runtime after' >"${positive_repo}/.opencode_web_yolo.sh" +commit_all "$positive_repo" "PR release changes" +git -C "$positive_repo" checkout -q -b base "$positive_base_ref" +git -C "$positive_repo" merge --no-ff pr -m "synthetic PR merge" >/dev/null 2>&1 + +positive_output="$( + VERSION_GUARD_BASE_REF="$positive_base_ref" \ + "${positive_repo}/tests/version_guard.sh" 2>&1 +)" +assert_contains "$positive_output" "Runtime/release files changed and VERSION was updated." + +masked_repo="${TMP_DIR}/masked-merge" +init_repo "$masked_repo" +printf '%s\n' '0.2.1' >"${masked_repo}/VERSION" +printf '%s\n' 'runtime before' >"${masked_repo}/.opencode_web_yolo.sh" +commit_all "$masked_repo" "base" +masked_original_base_ref="$(git -C "$masked_repo" rev-parse HEAD)" + +git -C "$masked_repo" checkout -q -b base "$masked_original_base_ref" +printf '%s\n' '0.3.0' >"${masked_repo}/VERSION" +commit_all "$masked_repo" "base branch version bump" +masked_current_base_ref="$(git -C "$masked_repo" rev-parse HEAD)" + +git -C "$masked_repo" checkout -q -b pr "$masked_original_base_ref" +printf '%s\n' 'runtime after' >"${masked_repo}/.opencode_web_yolo.sh" +commit_all "$masked_repo" $'PR runtime change\n\nparent this-is-commit-message-content' +git -C "$masked_repo" checkout -q base +git -C "$masked_repo" merge --no-ff pr -m "synthetic masked PR merge" >/dev/null 2>&1 +assert_equals "0.3.0" "$(git -C "$masked_repo" show HEAD:VERSION)" + +set +e +masked_output="$( + VERSION_GUARD_BASE_REF="$masked_current_base_ref" \ + "${masked_repo}/tests/version_guard.sh" 2>&1 +)" +masked_status=$? +set -e +assert_equals "1" "$masked_status" +assert_contains "$masked_output" "Runtime/release files changed but VERSION was not updated." -merge_output="$(${merge_repo}/tests/version_guard.sh 2>&1)" -assert_contains "$merge_output" "Runtime/release files changed and VERSION was updated." +missing_base_ref=0000000000000000000000000000000000000000 +set +e +missing_base_output="$( + VERSION_GUARD_BASE_REF="$missing_base_ref" \ + "${positive_repo}/tests/version_guard.sh" 2>&1 +)" +missing_base_status=$? +set -e +assert_equals "1" "$missing_base_status" +assert_contains "$missing_base_output" "Requested VERSION_GUARD_BASE_REF '${missing_base_ref}' is unavailable;" shallow_repo="${TMP_DIR}/shallow" -git clone -q --depth 1 --branch release "file://${merge_repo}" "$shallow_repo" -shallow_output="$(${shallow_repo}/tests/version_guard.sh 2>&1)" -assert_contains "$shallow_output" "Parent commit history unavailable; skipping runtime-file/version drift check." +git clone -q --depth 2 --branch base "file://${masked_repo}" "$shallow_repo" +set +e +shallow_output="$( + VERSION_GUARD_BASE_REF="$masked_original_base_ref" \ + "${shallow_repo}/tests/version_guard.sh" 2>&1 +)" +shallow_status=$? +set -e +assert_equals "1" "$shallow_status" +assert_contains "$shallow_output" "Requested VERSION_GUARD_BASE_REF '${masked_original_base_ref}' is unavailable;" -printf '%s\n' "PASS: version guard handles normal and merge commits" +printf '%s\n' "PASS: version guard handles normal, PR-base, root, and shallow commits" diff --git a/tests/version_guard.sh b/tests/version_guard.sh index b351525..7b121e5 100755 --- a/tests/version_guard.sh +++ b/tests/version_guard.sh @@ -9,40 +9,41 @@ if ! grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' VERSION; then exit 1 fi -head_object="$(git cat-file -p HEAD 2>/dev/null || true)" -parent_refs="" -while IFS= read -r line; do - case "$line" in - parent\ *) parent_refs="${parent_refs}${line#parent } " ;; - esac -done <<<"$head_object" -parent_refs="${parent_refs% }" -first_parent="${parent_refs%% *}" - -if [ -z "$first_parent" ]; then - printf '%s\n' "No parent commit found; skipping runtime-file/version drift check." - exit 0 -fi - -if ! git cat-file -e "${first_parent}^{commit}" >/dev/null 2>&1; then - printf '%s\n' "Parent commit history unavailable; skipping runtime-file/version drift check." - exit 0 +if ! parent_refs="$(git show -s --format=%P HEAD 2>/dev/null)"; then + printf '%s\n' "Unable to inspect HEAD parents; cannot verify runtime-file/version drift." >&2 + exit 1 fi -comparison_base="$first_parent" -if [[ "$parent_refs" == *" "* ]]; then - second_parent="${parent_refs#* }" - second_parent="${second_parent%% *}" +first_parent="${parent_refs%% *}" +version_guard_base_ref="${VERSION_GUARD_BASE_REF:-}" - if ! git cat-file -e "${second_parent}^{commit}" >/dev/null 2>&1; then - printf '%s\n' "Merge parent history unavailable; skipping runtime-file/version drift check." +if [ -z "$first_parent" ]; then + shallow_file="$(git rev-parse --git-path shallow 2>/dev/null || true)" + head_commit="$(git rev-parse HEAD 2>/dev/null || true)" + if [ -f "$shallow_file" ] && grep -Fqx "$head_commit" "$shallow_file"; then + printf '%s\n' "Parent commit history unavailable; cannot verify runtime-file/version drift. Fetch complete history (for example, use checkout fetch-depth: 0)." >&2 + exit 1 + fi + if [ -z "$version_guard_base_ref" ]; then + printf '%s\n' "No parent commit found; skipping runtime-file/version drift check." exit 0 fi +else + if ! git cat-file -e "${first_parent}^{commit}" >/dev/null 2>&1; then + printf '%s\n' "Parent commit history unavailable; cannot verify runtime-file/version drift. Fetch complete history (for example, use checkout fetch-depth: 0)." >&2 + exit 1 + fi +fi - if ! comparison_base="$(git merge-base "$first_parent" "$second_parent" 2>/dev/null)" || [ -z "$comparison_base" ]; then - printf '%s\n' "Merge base unavailable; skipping runtime-file/version drift check." - exit 0 +if [ -n "$version_guard_base_ref" ]; then + if ! git cat-file -e "${version_guard_base_ref}^{commit}" >/dev/null 2>&1; then + printf '%s\n' "Requested VERSION_GUARD_BASE_REF '${version_guard_base_ref}' is unavailable; cannot verify runtime-file/version drift. Fetch complete history (for example, use checkout fetch-depth: 0)." >&2 + exit 1 fi + comparison_base="$version_guard_base_ref" +else + # Without an explicit PR base, compare merge commits against their first parent. + comparison_base="$first_parent" fi changed_runtime="$(git diff --name-only "$comparison_base" HEAD -- \