build Verify master on the server

Move verification enforcement to the deployment host. The server checks candidate master before updating the branch, then deploys only an accepted revision. Remove optional client hooks so clones have no local push requirement.

Commit
58dbbb5608009e0a7c182ba6f8bd2bc90baac12d
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
.githooks/pre-push
index a3ad1f51..00000000 100755..000000
@@ -1,19 +0,0 @@
1 Removed: #!/usr/bin/env sh
2 Removed: # Run the definition-of-done gates before any push leaves this machine, so the
3 Removed: # deployment host's post-receive hook only ever sees a ref that type-checks,
4 Removed: # passes every test, and is formatted.
5 Removed: #
6 Removed: # Enable once per clone: scripts/install-hooks.sh
7 Removed: # Bypass deliberately: git push --no-verify
8 Removed:
9 Removed: set -eu
10 Removed:
11 Removed: cd "$(git rev-parse --show-toplevel)"
12 Removed:
13 Removed: # Prefer the project's opam switch when direnv provides it, so the hook uses the
14 Removed: # same toolchain as a developer shell.
15 Removed: if command -v direnv >/dev/null 2>&1; then
16 Removed: exec direnv exec . sh scripts/ci.sh
17 Removed: else
18 Removed: exec sh scripts/ci.sh
19 Removed: fi
README.org
index 1cb473b0..bba7e24f 100644..100644
@@ -73,15 +73,22 @@
73 73 #+end_src
74 74
75 75 Format the files that you change with ~ocamlformat --inplace~. Run
76 Removed: ~scripts/install-hooks.sh~ once for each clone. The pre-push hook then runs
77 Removed: ~scripts/ci.sh~ before each push.
76 Added: ~scripts/ci.sh~ when you need local verification. The deployment server
77 Added: verifies each candidate ~master~ revision before it accepts the update.
78 78
79 79 * Deploy
80 80
81 Removed: ~deploy/setup.sh~ prepares the deployment host. ~deploy/post-receive~ builds
82 Removed: each push to ~master~, then replaces the live release. The pre-push hook runs
83 Removed: the full verification gate before a push. If the health check fails, the hook
84 Removed: goes back to the previous release.
81 Added: ~deploy/setup.sh~ prepares the deployment host. Its ~install~ subcommand
82 Added: installs the trusted CI helper and the receive hooks. Run it again after a hook
83 Added: or CI helper update.
84 Added:
85 Added: The ~pre-receive~ hook archives and verifies a candidate ~master~ revision
86 Added: before Git updates ~master~. A failed check rejects the push. The
87 Added: ~post-receive~ hook builds and replaces the live release only after that check
88 Added: passes. The server installs test and formatting dependencies for verification.
89 Added:
90 Added: A client hook is a local safeguard. A clone can omit or bypass it. The
91 Added: ~pre-receive~ hook is the authority for ~master~ and deployment safety.
85 92
86 93 * Doctrine
87 94
deploy/post-receive
index 728ffe85..e62f8a01 100755..100755
@@ -6,11 +6,12 @@
6 6 # hook builds and publishes directly, with no privilege hop. The only
7 7 # privileged action is restarting the service, through one scoped sudoers rule.
8 8 #
9 Removed: # For a push to master: check out the pushed revision into a fresh timestamped
10 Removed: # release, build it against the shared opam switch, flip /srv/hito/current,
11 Removed: # restart the service, and confirm it answers HTTP. Any build failure leaves
12 Removed: # the live release untouched. A failed health check rolls current back to the
13 Removed: # previous release and restarts.
9 Added: # For an accepted push to master: check out the verified revision into a fresh
10 Added: # timestamped release, build it against the shared opam switch, flip
11 Added: # /srv/hito/current, restart the service, and confirm it answers HTTP. The
12 Added: # pre-receive hook rejects a failed master verification. Any build failure
13 Added: # leaves the live release untouched. A failed health check restores the
14 Added: # previous release.
14 15 #
15 16 # Style: Google Shell Style Guide.
16 17
@@ -58,21 +59,17 @@
58 59 git --git-dir="${BARE}" archive "${sha}" | tar -x -C "${rel}"
59 60 }
60 61
61 Removed: # Build the release artifact against the shared switch. The full log stays
62 Removed: # inside the release so a successful deploy leaves a record, not only a failed
63 Removed: # one.
62 Added: # Build the verified release artifact against the shared switch. The full log
63 Added: # stays inside the release so a successful deploy leaves a record, not only a
64 Added: # failed one.
64 65 build_release() {
65 66 local rel="$1"
66 67 local buildlog="${rel}/deploy.log"
67 68 eval "$(opam env --switch="${SHARED}" --set-switch)"
68 69 cd "${rel}"
69 70
70 Removed: err "syncing dependencies"
71 Removed: opam install . --deps-only -y >"${buildlog}" 2>&1 \
72 Removed: || { tail -30 "${buildlog}" >&2; return 1; }
73 Removed:
74 71 err "dune build (release)"
75 Removed: dune build >>"${buildlog}" 2>&1 \
72 Added: dune build >"${buildlog}" 2>&1 \
76 73 || { tail -40 "${buildlog}" >&2; return 1; }
77 74 }
78 75
deploy/pre-receive
index 00000000..2399c3bd 000000..100755
@@ -0,0 +1,76 @@
1 Added: #!/usr/bin/env bash
2 Added: # hito pre-receive hook. The bare repository runs this hook before it moves a
3 Added: # ref. The hook checks each candidate master revision in an isolated directory.
4 Added: # A failed check rejects the push, so an unverified revision cannot deploy.
5 Added: #
6 Added: # deploy/setup.sh installs this hook and its trusted hito-ci helper. The helper
7 Added: # is outside the received tree. A pushed revision cannot weaken this check.
8 Added: #
9 Added: # Style: Google Shell Style Guide.
10 Added:
11 Added: set -euo pipefail
12 Added:
13 Added: readonly DEPLOY_BRANCH="master"
14 Added: readonly BARE="/srv/git/hito.git"
15 Added: readonly SHARED="/srv/hito/shared"
16 Added: readonly CI_HELPER="${BARE}/hooks/hito-ci"
17 Added: readonly ZERO="0000000000000000000000000000000000000000"
18 Added:
19 Added: export OPAMROOT="/srv/git/.opam"
20 Added: export OPAMSWITCH="${SHARED}"
21 Added:
22 Added: err() {
23 Added: printf ' hito-verify: %s\n' "$*" >&2
24 Added: }
25 Added:
26 Added: fail() {
27 Added: err "REJECTED: $*"
28 Added: exit 1
29 Added: }
30 Added:
31 Added: verify_master() {
32 Added: local old="$1" new="$2"
33 Added: local stage buildlog
34 Added:
35 Added: if [[ "${new}" == "${ZERO}" ]]; then
36 Added: fail "master cannot be deleted"
37 Added: fi
38 Added: if [[ "${old}" != "${ZERO}" ]] \
39 Added: && ! git --git-dir="${BARE}" merge-base --is-ancestor "${old}" "${new}"; then
40 Added: fail "master must advance by fast-forward"
41 Added: fi
42 Added: if [[ ! -x "${CI_HELPER}" ]]; then
43 Added: fail "missing trusted CI helper; run deploy/setup.sh install"
44 Added: fi
45 Added:
46 Added: stage="$(mktemp -d /tmp/hito-verify.XXXXXX)"
47 Added: trap 'rm -rf "${stage}"' RETURN
48 Added: buildlog="${stage}/verify.log"
49 Added:
50 Added: err "verifying candidate master ${new:0:12}"
51 Added: git --git-dir="${BARE}" archive "${new}" | tar -x -C "${stage}" \
52 Added: || fail "archive candidate master"
53 Added:
54 Added: eval "$(opam env --switch="${SHARED}" --set-switch)"
55 Added: (
56 Added: cd "${stage}"
57 Added: opam install . --deps-only --with-test --with-dev-setup -y >"${buildlog}" 2>&1
58 Added: BUILD_DIR="_build-ci" sh "${CI_HELPER}" >>"${buildlog}" 2>&1
59 Added: ) || {
60 Added: tail -40 "${buildlog}" >&2 || true
61 Added: fail "verification failed for ${new:0:12}"
62 Added: }
63 Added:
64 Added: err "verified candidate master ${new:0:12}"
65 Added: }
66 Added:
67 Added: main() {
68 Added: local old new ref
69 Added: while read -r old new ref; do
70 Added: if [[ "${ref}" == "refs/heads/${DEPLOY_BRANCH}" ]]; then
71 Added: verify_master "${old}" "${new}"
72 Added: fi
73 Added: done
74 Added: }
75 Added:
76 Added: main "$@"
deploy/setup.sh
index cc4bfd47..904106f9 100755..100755
@@ -5,8 +5,8 @@
5 5 # sudo bash setup.sh install One-time host setup: create the git-owned
6 6 # /srv/hito layout, the hito runtime user and
7 7 # its data/ directory, build the shared switch as
8 Removed: # git, install the hook, the scoped sudoers rule,
9 Removed: # and the systemd unit from hito.service beside
8 Added: # git, install the receive hooks, the scoped sudoers
9 Added: # rule, and the systemd unit from hito.service beside
10 10 # this script. Idempotent. Creates no release;
11 11 # the first push (or 'deploy') does.
12 12 # sudo bash setup.sh deploy Reinstall hito.service if it changed, build
@@ -23,8 +23,8 @@
23 23 # 'systemctl restart hito', through a scoped sudoers rule. The hito system user
24 24 # runs the service and owns only /srv/hito/data, which holds the database. A
25 25 # compromised service cannot rewrite releases or the deploy pipeline, and git
26 Removed: # cannot read the database. The post-receive hook beside this script is the
27 Removed: # per-push equivalent of 'deploy'.
26 Added: # cannot read the database. The receive hooks enforce the master check before
27 Added: # the post-receive hook deploys each accepted push.
28 28 #
29 29 # Style: Google Shell Style Guide.
30 30
@@ -79,10 +79,14 @@
79 79 as_svc opam switch create "${SHARED}" "${OCAML_COMPILER}" --no-install -y
80 80 }
81 81
82 Removed: install_hook() {
82 Added: install_hooks() {
83 83 install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \
84 Added: "${HERE}/pre-receive" "${BARE}/hooks/pre-receive"
85 Added: install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \
84 86 "${HERE}/post-receive" "${BARE}/hooks/post-receive"
85 Removed: echo " installed ${BARE}/hooks/post-receive"
87 Added: install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \
88 Added: "${HERE}/../scripts/ci.sh" "${BARE}/hooks/hito-ci"
89 Added: echo " installed receive hooks and trusted CI helper"
86 90 }
87 91
88 92 # Create the unprivileged runtime account once. No login shell, no home.
@@ -157,10 +161,10 @@
157 161 git --git-dir='${BARE}' archive '${sha}' | tar -x -C '${stage}'
158 162 cd '${stage}'
159 163 eval \"\$(opam env --switch='${SHARED}' --set-switch)\"
160 Removed: opam install . --deps-only --with-test -y 2>&1 | tee deploy.log
161 Removed: dune build @check 2>&1 | tee -a deploy.log
162 Removed: dune runtest 2>&1 | tee -a deploy.log
163 Removed: dune build 2>&1 | tee -a deploy.log
164 Added: opam install . --deps-only --with-test --with-dev-setup -y \
165 Added: 2>&1 | tee deploy.log
166 Added: BUILD_DIR=_build-ci sh '${HERE}/../scripts/ci.sh' 2>&1 | tee -a deploy.log
167 Added: dune build 2>&1 | tee -a deploy.log
164 168 "
165 169 build_secs=$((SECONDS - start))
166 170
@@ -227,7 +231,7 @@
227 231 mkdir -p "${OPAMROOT_DIR}"
228 232 chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}"
229 233 echo "== shared opam switch =="; ensure_switch
230 Removed: echo "== post-receive hook =="; install_hook
234 Added: echo "== receive hooks =="; install_hooks
231 235 echo "== scoped sudoers =="; install_sudoers
232 236 echo "== systemd unit =="; install_unit
233 237 echo
scripts/ci.sh
index a25b71ce..7035a97b 100755..100755
@@ -5,10 +5,9 @@
5 5 # 2. dune runtest runs every Alcotest suite
6 6 # 3. dune build @fmt confirms ocamlformat and dune formatting are applied
7 7 #
8 Removed: # Exit status is non-zero if any gate fails, so the pre-push hook in .githooks,
9 Removed: # a cron job, or the deployment host's receive hook can all enforce the same bar
10 Removed: # with one call. The build directory is isolated so a developer's watch build
11 Removed: # is never disturbed.
8 Added: # Exit status is non-zero if any gate fails, so a cron job or the deployment
9 Added: # host's trusted receive-hook helper can enforce the same bar with one call. The
10 Added: # build directory is isolated so a developer's watch build is never disturbed.
12 11 #
13 12 # Usage: scripts/ci.sh run all gates
14 13 # BUILD_DIR=... scripts/ci.sh override the build directory
scripts/install-hooks.sh
index 2e6d82a0..00000000 100755..000000
@@ -1,10 +0,0 @@
1 Removed: #!/usr/bin/env sh
2 Removed: # Point git at the versioned hooks in .githooks, so the pre-push gate is part of
3 Removed: # the repository rather than a per-clone manual step. Run once per clone.
4 Removed:
5 Removed: set -eu
6 Removed:
7 Removed: cd "$(git rev-parse --show-toplevel)"
8 Removed: git config core.hooksPath .githooks
9 Removed: chmod +x .githooks/*
10 Removed: printf 'git hooks enabled: %s\n' "$(git config --get core.hooksPath)"