#!/usr/bin/env bash # # hito post-receive hook. Runs as the git user after every push to the bare # repo. The git user owns the bare repo and /srv/hito (except data/, which # belongs to the hito runtime user), so this # hook builds and publishes directly, with no privilege hop. The only # privileged action is restarting the service, through one scoped sudoers rule. # # For an accepted push to master: check out the verified revision into a fresh # timestamped release, build it against the shared opam switch, flip # /srv/hito/current, restart the service, and confirm it answers HTTP. The # pre-receive hook rejects a failed master verification. Any build failure # leaves the live release untouched. A failed health check restores the # previous release. # # Style: Google Shell Style Guide. set -euo pipefail readonly DEPLOY_BRANCH="master" readonly SRV="/srv/hito" readonly BARE="/srv/git/hito.git" readonly SHARED="${SRV}/shared" readonly RELEASES="${SRV}/releases" readonly KEEP=5 readonly HEALTH_URL="http://127.0.0.1:8080/login" readonly HEALTH_TRIES=10 readonly HEALTH_DELAY=1 export OPAMROOT="/srv/git/.opam" export OPAMSWITCH="${SHARED}" err() { printf ' hito-deploy: %s\n' "$*" >&2 } fail() { err "FAILED: $*" exit 1 } # Poll the app until it answers, or fail after HEALTH_TRIES attempts. health_ok() { local i for ((i = 0; i < HEALTH_TRIES; i++)); do if curl -sf -o /dev/null --max-time 3 "${HEALTH_URL}"; then return 0 fi sleep "${HEALTH_DELAY}" done return 1 } # Materialize the pushed revision from the bare repo into ${rel}. git archive # yields exactly the tracked tree, never a stray shared/ or releases/ directory; # those are strict siblings of a release and must never appear inside one. checkout_release() { local sha="$1" rel="$2" git --git-dir="${BARE}" archive "${sha}" | tar -x -C "${rel}" } # Build the verified release artifact against the shared switch. The full log # stays inside the release so a successful deploy leaves a record, not only a # failed one. build_release() { local rel="$1" local buildlog="${rel}/deploy.log" eval "$(opam env --switch="${SHARED}" --set-switch)" cd "${rel}" err "dune build (release)" dune build >"${buildlog}" 2>&1 \ || { tail -40 "${buildlog}" >&2; return 1; } } # Write per-release provenance and append one durable history line. record_metadata() { local sha="$1" rel="$2" ts="$3" build_secs="$4" local subject version subject="$(git --git-dir="${BARE}" log -1 --format=%s "${sha}")" version="$(git --git-dir="${BARE}" log -1 --format=%cd \ --date=format:%Y%m%d "${sha}")" jq -n \ --arg commit "${sha}" \ --arg subject "${subject}" \ --arg branch "${DEPLOY_BRANCH}" \ --arg deployed_at "${ts}" \ --argjson build_seconds "${build_secs}" \ --arg release "${rel}" \ '{commit: $commit, subject: $subject, branch: $branch, deployed_at: $deployed_at, build_seconds: $build_seconds, release: $release}' \ >"${rel}/deploy-info.json" printf '%s\t%s\t%ss\t%s\t%s\n' \ "${ts}" "${sha:0:12}" "${build_secs}" "${DEPLOY_BRANCH}" "${subject}" \ >>"${SHARED}/deploy-history.tsv" printf 'HITO_VERSION=%s\n' "${version}" >"${SHARED}/deploy.env" } # Point current at ${rel} in a single rename so no request sees a half state. flip_current() { local rel="$1" ln -sfn "${rel}" "${SRV}/current.tmp" mv -T "${SRV}/current.tmp" "${SRV}/current" } # Restart, health-check, and roll back to ${prev} if the new release is unwell. restart_and_verify() { local prev="$1" sudo -n systemctl restart hito || fail "restart" if health_ok; then err "healthy: ${HEALTH_URL}" return 0 fi if [[ -n "${prev}" && -d "${prev}" ]]; then err "health check failed; rolling back to ${prev}" flip_current "${prev}" sudo -n systemctl restart hito || true fail "health check (rolled back to previous release)" fi fail "health check (no previous release to roll back to)" } # Remove all but the most recent KEEP releases. prune_releases() { ls -1dt "${RELEASES}"/*/ 2>/dev/null | tail -n +$((KEEP + 1)) \ | xargs -r rm -rf } deploy_ref() { local sha="$1" local start="${SECONDS}" local ts rel prev build_secs ts="$(date -u +"%Y-%m-%dT%H-%M-%SZ")" rel="${RELEASES}/${ts}" prev="$(readlink -f "${SRV}/current" 2>/dev/null || true)" err "deploying ${sha:0:12} to ${rel}" mkdir -p "${rel}" checkout_release "${sha}" "${rel}" || { rm -rf "${rel}"; fail "checkout"; } build_release "${rel}" || { rm -rf "${rel}"; fail "build"; } build_secs=$((SECONDS - start)) ln -sfn "${SRV}/data/hito.sqlite" "${rel}/hito.sqlite" record_metadata "${sha}" "${rel}" "${ts}" "${build_secs}" flip_current "${rel}" err "current -> ${rel}" restart_and_verify "${prev}" err "deployed ${sha:0:12} in ${build_secs}s" prune_releases err "done" } main() { local _old new ref while read -r _old new ref; do if [[ "${ref}" != "refs/heads/${DEPLOY_BRANCH}" ]]; then err "skipping ${ref} (only ${DEPLOY_BRANCH} deploys)" continue fi deploy_ref "${new}" done } main "$@"