diff --git a/README.md b/README.md index 1bd4984..1ba16bd 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ prebuilt package assets, Monaco, and the persistent SQLite volume. Configure the path in `compose.yaml`. The default port binding is private to the VPS at `127.0.0.1:5002`; put Caddy or nginx in front of it for HTTPS. Follow logs with `docker compose logs -f yawn`. +To check for new pushes every minute, add this entry with `crontab -e`: + +```cron +* * * * * $HOME/yawn/server/poll-deploy.sh >> $HOME/yawn-deploy.log 2>&1 +``` + +The poller compares `origin/main` with the last healthy deployed revision and does nothing when they +match. A shared lock prevents overlapping manual and scheduled deployments. Failed deployments do +not update the revision marker, so the next poll retries them. + Run the previous VitePress documentation site with `npm start`. Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients. diff --git a/server/deploy-docker.sh b/server/deploy-docker.sh index e8ae569..b646545 100755 --- a/server/deploy-docker.sh +++ b/server/deploy-docker.sh @@ -3,13 +3,16 @@ set -euo pipefail repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" expected_repository="$HOME/yawn" +state_directory="$HOME/.local/state/yawn" +deployed_revision_file="$state_directory/deployed-revision" +compose=(docker compose --project-name yawn --project-directory "$repository" --file "$repository/compose.yaml") if [[ "$repository" != "$expected_repository" ]]; then printf 'Expected the repository at %s, found %s\n' "$expected_repository" "$repository" >&2 exit 1 fi -for command in git docker; do +for command in git docker flock; do if ! command -v "$command" >/dev/null 2>&1; then printf 'Missing required command: %s\n' "$command" >&2 exit 1 @@ -20,6 +23,13 @@ if ! docker compose version >/dev/null 2>&1; then exit 1 fi +mkdir -p "$state_directory" +exec 9>"$state_directory/deploy.lock" +if ! flock -n 9; then + printf 'Another Yawn deployment is already running.\n' + exit 0 +fi + cd "$repository" if [[ "$(git branch --show-current)" != "main" ]]; then @@ -41,10 +51,10 @@ if [[ "$(git rev-parse HEAD)" != "$fetched" ]]; then fi printf 'Building the production image...\n' -docker compose build --pull yawn -docker compose up -d --remove-orphans yawn +"${compose[@]}" build --pull yawn +"${compose[@]}" up -d --remove-orphans yawn -container="$(docker compose ps -q yawn)" +container="$("${compose[@]}" ps -q yawn)" if [[ -z "$container" ]]; then printf 'Docker Compose did not create the Yawn container.\n' >&2 exit 1 @@ -55,11 +65,14 @@ for _ in {1..60}; do status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container")" case "$status" in healthy) + temporary_revision="$(mktemp "$state_directory/deployed-revision.XXXXXX")" + git rev-parse HEAD >"$temporary_revision" + mv -f -- "$temporary_revision" "$deployed_revision_file" printf 'The Yawn container is healthy.\n' exit 0 ;; unhealthy | exited | dead) - docker compose logs --tail 50 yawn >&2 + "${compose[@]}" logs --tail 50 yawn >&2 exit 1 ;; esac @@ -67,5 +80,5 @@ for _ in {1..60}; do done printf 'Yawn did not become healthy before the timeout.\n' >&2 -docker compose logs --tail 50 yawn >&2 +"${compose[@]}" logs --tail 50 yawn >&2 exit 1 diff --git a/server/poll-deploy.sh b/server/poll-deploy.sh new file mode 100755 index 0000000..6fb12e7 --- /dev/null +++ b/server/poll-deploy.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +expected_repository="$HOME/yawn" +state_directory="$HOME/.local/state/yawn" +deployed_revision_file="$state_directory/deployed-revision" +compose=(docker compose --project-name yawn --project-directory "$repository" --file "$repository/compose.yaml") + +if [[ "$repository" != "$expected_repository" ]]; then + printf 'Expected the repository at %s, found %s\n' "$expected_repository" "$repository" >&2 + exit 1 +fi + +for command in git docker flock; do + if ! command -v "$command" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$command" >&2 + exit 1 + fi +done +if ! docker compose version >/dev/null 2>&1; then + printf 'Docker Compose v2 is required.\n' >&2 + exit 1 +fi + +mkdir -p "$state_directory" +exec 9>"$state_directory/deploy.lock" +if ! flock -n 9; then + printf 'Another Yawn deployment is already running.\n' + exit 0 +fi + +cd "$repository" +if [[ "$(git branch --show-current)" != "main" ]]; then + printf 'Deployments must run from the main branch.\n' >&2 + exit 1 +fi +if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + printf 'Refusing to deploy a checkout with local changes.\n' >&2 + exit 1 +fi + +git fetch origin main +remote_revision="$(git rev-parse FETCH_HEAD)" +current_revision="$(git rev-parse HEAD)" +deployed_revision="" +if [[ -f "$deployed_revision_file" ]]; then + read -r deployed_revision <"$deployed_revision_file" +fi + +container="$("${compose[@]}" ps -q yawn 2>/dev/null || true)" +container_status="missing" +if [[ -n "$container" ]]; then + container_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container" 2>/dev/null || true)" +fi + +if [[ "$current_revision" == "$remote_revision" && + "$deployed_revision" == "$remote_revision" && + "$container_status" == "healthy" ]]; then + printf 'Yawn is already running revision %s.\n' "${remote_revision:0:12}" + exit 0 +fi + +printf 'Deploying revision %s...\n' "${remote_revision:0:12}" +flock -u 9 +exec 9>&- +"$repository/server/deploy-docker.sh"