Bläddra i källkod

Add deploy pipeline: deployd, release, config and install docs

- deployd: Gogs webhook receiver; checks the HMAC signature, releases
  only PRs merged into main, one at a time. Refuses to start without a
  secret. Tests cover signature, event and action filtering, and
  serialised releases.
- release: builds and deploys one exact commit from sites.json, syncs a
  Go app's content/ folder, tags deploy/<target>/<time>, logs it. Locks
  against concurrent runs and validates its arguments. test.sh runs it
  end to end in a sandbox (20 checks).
- sites.json with bajdev/posts, deployd.service, a regex sudo rule for
  the deploy user, and a copy of issue-cert.
- README with install, onboarding, checks and rollback; MIT license.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 21 timmar sedan
förälder
incheckning
536e25ab99
12 ändrade filer med 576 tillägg och 3 borttagningar
  1. 1 0
      .gitignore
  2. 21 0
      LICENSE
  3. 157 3
      README.md
  4. 11 0
      certs/issue-cert
  5. 3 0
      deployd/go.mod
  6. 104 0
      deployd/main.go
  7. 103 0
      deployd/main_test.go
  8. 70 0
      release/release
  9. 87 0
      release/test.sh
  10. 3 0
      sites.json
  11. 3 0
      sudoers/60-deploy
  12. 13 0
      systemd/deployd.service

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+/deployd/deployd

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 bajdev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 157 - 3
README.md

@@ -3,6 +3,160 @@
 Apps and scripts built as part of the DevOps pipeline for the monoaf.com
 server.
 
-The first pull request adds the deploy pipeline: `deployd`, a webhook
-receiver that releases a repo when a pull request is merged into `main`, and
-the `release` script it runs.
+The main piece is the **deploy pipeline**: when a pull request is merged into
+`main` in Gogs, the exact merge commit is built, deployed, tagged
+`deploy/<target>/<UTC time>` and logged.
+
+```
+ PR merged in Gogs
+   │  webhook (HMAC-signed), 127.0.0.1:9000/deploy
+   ▼
+ deployd  ── checks signature, merged into main ──▶  release <repo> <sha>
+                                                     │
+              clean checkout of that commit ◀────────┤
+              go build  /  rsync static files ◀──────┤
+              rsync content/ (if configured) ◀───────┤
+              sudo systemctl restart goapp@<name> ◀──┤
+              tag deploy/<target>/<time>, push ◀─────┤
+              line in /var/log/deploys.log ◀─────────┘
+```
+
+## Layout
+
+| Path | Installed as | What it is |
+|---|---|---|
+| `deployd/` | `/usr/local/bin/deployd` | Webhook receiver (Go, standard library only) |
+| `release/release` | `/usr/local/bin/release` | Builds, deploys, tags and logs one commit |
+| `release/test.sh` | not installed | Runs `release` end to end in a sandbox |
+| `sites.json` | `/etc/deployd/sites.json` | Which repo deploys to which site |
+| `systemd/deployd.service` | `/etc/systemd/system/` | Runs `deployd` as the `deploy` user |
+| `sudoers/60-deploy` | `/etc/sudoers.d/60-deploy` | Lets `deploy` restart `goapp@<name>` only |
+| `certs/issue-cert` | `/usr/local/sbin/issue-cert` | Certificate wrapper (Phase 10 of the server plan) |
+
+Everything is installed by the admin, owned by root. The `claude` user can
+propose changes here through pull requests but can't install anything.
+
+## sites.json
+
+```json
+{
+  "bajdev/posts": { "kind": "go", "name": "posts", "content": "content" }
+}
+```
+
+| Key | Kinds | Meaning |
+|---|---|---|
+| `kind` | all | `go` (a `goapp@<name>` app) or `static` (files served by nginx) |
+| `name` | go | App name: binary at `/opt/apps/<name>/<name>`, unit `goapp@<name>` |
+| `content` | go | Optional folder in the repo synced to `/opt/apps/<name>/content/` |
+| `host` | static | Site under `/srv/www/vhosts/<host>/` |
+| `dir` | static | Folder in the repo to publish (default `public`) |
+
+Repos not listed are refused.
+
+## Install (once, as the admin)
+
+Steps 1–4 match Phase 7 of the server plan.
+
+**1. The `deploy` user**, its log, and its sudo rule:
+
+```bash
+sudo useradd -r -m -d /var/lib/deploy -s /bin/bash -G webops deploy
+sudo install -m 664 -o deploy -g webops /dev/null /var/log/deploys.log
+sudo -iu deploy sh -c 'mkdir -p -m 700 ~/.ssh && ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519 -C deploy@vps'
+sudo -iu deploy git config --global user.name Deploy
+sudo -iu deploy git config --global user.email deploy@monoaf.com
+sudo cat /var/lib/deploy/.ssh/id_ed25519.pub        # for the Gogs deploy account
+```
+
+In Gogs, create a `deploy` user, add that key to it, and add it as a **Write**
+collaborator on each repo it releases. It needs Write to push tags; branch
+protection still stops it pushing to `main`. Then trust Gogs' host key:
+
+```bash
+sudo -iu deploy ssh -o StrictHostKeyChecking=accept-new git@git.monoaf.com true
+```
+
+**2. Build and install** from a checkout of this repo:
+
+```bash
+git clone https://git.monoaf.com/bajdev/devops.git ~/devops && cd ~/devops
+(cd deployd && go test ./... && CGO_ENABLED=0 go build -trimpath -o deployd .)
+sudo install -o root -g root -m 755 deployd/deployd /usr/local/bin/deployd
+sudo install -o root -g root -m 755 release/release /usr/local/bin/release
+sudo install -d -o root -g root -m 755 /etc/deployd
+sudo install -o root -g root -m 644 sites.json /etc/deployd/sites.json
+sudo install -o root -g root -m 644 systemd/deployd.service /etc/systemd/system/deployd.service
+# sudo rule: install under a name sudo ignores, check it, then move it into place
+sudo install -o root -g root -m 440 sudoers/60-deploy /etc/sudoers.d/60-deploy.new
+sudo visudo -cf /etc/sudoers.d/60-deploy.new && sudo mv /etc/sudoers.d/60-deploy.new /etc/sudoers.d/60-deploy
+```
+
+**3. Secret and service:**
+
+```bash
+printf 'WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 32)" | sudo tee /etc/deployd/secret.env >/dev/null
+sudo chmod 600 /etc/deployd/secret.env
+sudo systemctl daemon-reload && sudo systemctl enable --now deployd
+sudo cat /etc/deployd/secret.env        # the secret for the Gogs webhook
+```
+
+**4. Let Gogs call it.** Since 0.14.3 Gogs refuses webhooks to local
+addresses unless allowed. In `/home/git/gogs/custom/conf/app.ini`, then
+`sudo systemctl restart gogs`:
+
+```ini
+[security]
+LOCAL_NETWORK_ALLOWLIST = 127.0.0.1
+```
+
+## Moving a site onto the pipeline
+
+1. Add it to `sites.json` (through a PR here) and reinstall that file.
+2. Hand its deployed files to `deploy`, so `release` can update them. For a
+   Go app:
+
+   ```bash
+   sudo chown -R deploy /opt/apps/<name>/<name> /opt/apps/<name>/content
+   ```
+
+   Leave `data/` alone. After this, deploy the site only through the
+   pipeline; `deploy-go` is for a site's first deploy.
+3. In the repo's Gogs settings: **Webhooks → Add Webhook → Gogs**. Payload URL
+   `http://127.0.0.1:9000/deploy`, content type `application/json`, secret
+   from `/etc/deployd/secret.env`, events "Let me choose what I need" → only
+   **Pull Request**.
+4. Merge a test PR and check the result (below).
+
+## Checking it works
+
+- A merged PR produces a `deploy/<target>/...` tag in Gogs and a line in
+  `/var/log/deploys.log`.
+- A PR closed without merging deploys nothing.
+- `journalctl -u deployd` shows each release's output.
+- In Gogs, the webhook's **Recent Deliveries** shows each request and
+  deployd's reply: `202` started a release, `204` was ignored, `401` was a
+  bad signature.
+
+## Rollback
+
+Redeploy any earlier release by its tag, as the admin:
+
+```bash
+sudo -iu deploy env DEPLOY_BY="rollback by bajdev" release bajdev/posts deploy/posts/20261026T120000Z
+```
+
+That checks out the tag, redeploys it, and records the rollback as a new tag
+and log line.
+
+## Development
+
+```bash
+(cd deployd && go vet ./... && go test -race ./...)
+release/test.sh ~/src/posts      # sandboxed end-to-end run of release
+```
+
+`release/test.sh` uses a local bare git remote and stand-in `sudo` and
+`systemctl`, so nothing on the server is touched. It covers a Go release
+with content sync, a rollback, a static site, refused input, and a service
+that fails to start (no tag, no log line).

+ 11 - 0
certs/issue-cert

@@ -0,0 +1,11 @@
+#!/bin/bash
+# issue-cert <host> — request a Let's Encrypt cert using the shared ACME webroot.
+# Run through sudo by the deploy user; accepts a single *.monoaf.com hostname.
+set -euo pipefail
+PATH=/usr/sbin:/usr/bin:/sbin:/bin
+[[ $# -eq 1 ]] || { echo "usage: issue-cert <host>" >&2; exit 2; }
+host=$1
+[[ $host =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+monoaf\.com$ ]] \
+  || { echo "issue-cert: '$host' is not a monoaf.com hostname" >&2; exit 2; }
+exec certbot certonly --non-interactive --webroot -w /srv/www/acme \
+  -d "$host" --deploy-hook 'systemctl reload nginx'

+ 3 - 0
deployd/go.mod

@@ -0,0 +1,3 @@
+module git.monoaf.com/bajdev/devops/deployd
+
+go 1.27.3

+ 104 - 0
deployd/main.go

@@ -0,0 +1,104 @@
+// deployd receives Gogs webhooks and releases a repo when a pull request is
+// merged into main. It listens on 127.0.0.1 only; Gogs reaches it locally.
+package main
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"os/exec"
+	"sync"
+	"time"
+)
+
+const releaseBin = "/usr/local/bin/release"
+
+type payload struct {
+	Action      string `json:"action"`
+	PullRequest struct {
+		Merged         bool   `json:"merged"`
+		BaseBranch     string `json:"base_branch"`
+		MergeCommitSHA string `json:"merge_commit_sha"`
+	} `json:"pull_request"`
+	Repository struct {
+		FullName string `json:"full_name"`
+	} `json:"repository"`
+	Sender struct {
+		Username string `json:"username"`
+	} `json:"sender"`
+}
+
+// releaser runs one release; swapped out in tests.
+type releaser func(repo, sha, by string)
+
+// handler verifies the Gogs signature and starts a release for a pull
+// request merged into main. Releases run one at a time, in the background,
+// so Gogs gets its reply straight away.
+func handler(secret []byte, release releaser) http.Handler {
+	var mu sync.Mutex
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost {
+			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+		body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+		if err != nil {
+			http.Error(w, "read", http.StatusBadRequest)
+			return
+		}
+		mac := hmac.New(sha256.New, secret)
+		mac.Write(body)
+		want := hex.EncodeToString(mac.Sum(nil))
+		if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Gogs-Signature"))) {
+			http.Error(w, "bad signature", http.StatusUnauthorized)
+			return
+		}
+		var p payload
+		if r.Header.Get("X-Gogs-Event") != "pull_request" || json.Unmarshal(body, &p) != nil {
+			w.WriteHeader(http.StatusNoContent)
+			return
+		}
+		pr := p.PullRequest
+		if p.Action != "closed" || !pr.Merged || pr.BaseBranch != "main" || pr.MergeCommitSHA == "" || p.Repository.FullName == "" {
+			w.WriteHeader(http.StatusNoContent)
+			return
+		}
+		w.WriteHeader(http.StatusAccepted)
+		go func() {
+			mu.Lock()
+			defer mu.Unlock()
+			release(p.Repository.FullName, pr.MergeCommitSHA, "PR merged by "+p.Sender.Username)
+		}()
+	})
+}
+
+// runRelease calls the release script and logs its output to the journal.
+func runRelease(repo, sha, by string) {
+	cmd := exec.Command(releaseBin, repo, sha)
+	cmd.Env = append(os.Environ(), "DEPLOY_BY="+by)
+	out, err := cmd.CombinedOutput()
+	log.Printf("release %s %s err=%v\n%s", repo, sha, err, out)
+}
+
+func main() {
+	secret := os.Getenv("WEBHOOK_SECRET")
+	if secret == "" {
+		log.Fatal("WEBHOOK_SECRET not set")
+	}
+	mux := http.NewServeMux()
+	mux.Handle("/deploy", handler([]byte(secret), runRelease))
+	srv := &http.Server{
+		Addr:              "127.0.0.1:9000",
+		Handler:           mux,
+		ReadHeaderTimeout: 5 * time.Second,
+		ReadTimeout:       10 * time.Second,
+		WriteTimeout:      10 * time.Second,
+	}
+	log.Printf("listening on %s", srv.Addr)
+	log.Fatal(srv.ListenAndServe())
+}

+ 103 - 0
deployd/main_test.go

@@ -0,0 +1,103 @@
+package main
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+)
+
+const secret = "test-secret"
+
+func sign(body string) string {
+	mac := hmac.New(sha256.New, []byte(secret))
+	mac.Write([]byte(body))
+	return hex.EncodeToString(mac.Sum(nil))
+}
+
+type call struct{ repo, sha, by string }
+
+func TestHandler(t *testing.T) {
+	merged := `{"action":"closed","pull_request":{"merged":true,"base_branch":"main","merge_commit_sha":"abc123"},"repository":{"full_name":"bajdev/posts"},"sender":{"username":"bajdev"}}`
+	tests := []struct {
+		name, method, event, body, sig string
+		status                         int
+		released                       bool
+	}{
+		{"merged into main", "POST", "pull_request", merged, "", 202, true},
+		{"bad signature", "POST", "pull_request", merged, "deadbeef", 401, false},
+		{"missing signature", "POST", "pull_request", merged, "-", 401, false},
+		{"GET", "GET", "pull_request", merged, "", 405, false},
+		{"push event", "POST", "push", merged, "", 204, false},
+		{"opened", "POST", "pull_request", strings.Replace(merged, `"closed"`, `"opened"`, 1), "", 204, false},
+		{"closed without merge", "POST", "pull_request", strings.Replace(merged, `"merged":true`, `"merged":false`, 1), "", 204, false},
+		{"merged into another branch", "POST", "pull_request", strings.Replace(merged, `"main"`, `"dev"`, 1), "", 204, false},
+		{"no merge commit", "POST", "pull_request", strings.Replace(merged, `"abc123"`, `""`, 1), "", 204, false},
+		{"no repository", "POST", "pull_request", strings.Replace(merged, `"bajdev/posts"`, `""`, 1), "", 204, false},
+		{"not JSON", "POST", "pull_request", "not json", "", 204, false},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			calls := make(chan call, 1)
+			h := handler([]byte(secret), func(repo, sha, by string) { calls <- call{repo, sha, by} })
+			req := httptest.NewRequest(tt.method, "/deploy", strings.NewReader(tt.body))
+			req.Header.Set("X-Gogs-Event", tt.event)
+			switch tt.sig {
+			case "":
+				req.Header.Set("X-Gogs-Signature", sign(tt.body))
+			case "-":
+			default:
+				req.Header.Set("X-Gogs-Signature", tt.sig)
+			}
+			rec := httptest.NewRecorder()
+			h.ServeHTTP(rec, req)
+			if rec.Code != tt.status {
+				t.Fatalf("status = %d, want %d", rec.Code, tt.status)
+			}
+			select {
+			case c := <-calls:
+				if !tt.released {
+					t.Fatalf("unexpected release %+v", c)
+				}
+				if c != (call{"bajdev/posts", "abc123", "PR merged by bajdev"}) {
+					t.Errorf("release called with %+v", c)
+				}
+			case <-time.After(200 * time.Millisecond):
+				if tt.released {
+					t.Fatal("release was not called")
+				}
+			}
+		})
+	}
+}
+
+func TestReleasesRunOneAtATime(t *testing.T) {
+	body := `{"action":"closed","pull_request":{"merged":true,"base_branch":"main","merge_commit_sha":"x"},"repository":{"full_name":"a/b"}}`
+	running, maxRunning := 0, 0
+	done := make(chan struct{}, 3)
+	h := handler([]byte(secret), func(repo, sha, by string) {
+		// The handler's mutex is held here, so these updates don't race.
+		running++
+		if running > maxRunning {
+			maxRunning = running
+		}
+		time.Sleep(20 * time.Millisecond)
+		running--
+		done <- struct{}{}
+	})
+	for i := 0; i < 3; i++ {
+		req := httptest.NewRequest("POST", "/deploy", strings.NewReader(body))
+		req.Header.Set("X-Gogs-Event", "pull_request")
+		req.Header.Set("X-Gogs-Signature", sign(body))
+		h.ServeHTTP(httptest.NewRecorder(), req)
+	}
+	for i := 0; i < 3; i++ {
+		<-done
+	}
+	if maxRunning != 1 {
+		t.Errorf("max concurrent releases = %d, want 1", maxRunning)
+	}
+}

+ 70 - 0
release/release

@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# release <owner/repo> <commit-or-tag>
+#
+# Builds and deploys one exact commit of a repo listed in sites.json, then
+# tags it deploy/<target>/<UTC time> in Gogs and appends a line to the
+# deploy log. Runs as the deploy user, from deployd or by hand for a
+# rollback. Unknown repos are refused.
+#
+# The RELEASE_* variables exist only so tests can point the script at a
+# sandbox; on the server they are never set.
+set -euo pipefail
+
+conf=${RELEASE_CONF:-/etc/deployd/sites.json}
+git_base=${RELEASE_GIT_BASE:-git@git.monoaf.com:}
+work_root=${RELEASE_WORK:-/var/lib/deploy/src}
+apps=${RELEASE_APPS:-/opt/apps}
+vhosts=${RELEASE_VHOSTS:-/srv/www/vhosts}
+logfile=${RELEASE_LOG:-/var/log/deploys.log}
+by=${DEPLOY_BY:-manual}
+
+[[ $# -eq 2 ]] || { echo "usage: release <owner/repo> <commit-or-tag>" >&2; exit 2; }
+repo=$1; ref=$2
+[[ $repo =~ ^[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_-][A-Za-z0-9_.-]*$ ]] || { echo "bad repo name: $repo" >&2; exit 2; }
+[[ $ref =~ ^[A-Za-z0-9_./-]+$ && $ref != -* ]] || { echo "bad ref: $ref" >&2; exit 2; }
+
+site=$(jq -ce --arg r "$repo" '.[$r]' "$conf") || { echo "unknown repo $repo" >&2; exit 1; }
+kind=$(jq -r .kind <<<"$site")
+
+# One release at a time, including manual ones.
+mkdir -p "$work_root"
+exec 9>"$work_root/.release.lock"
+flock 9
+
+work=$work_root/${repo//\//_}
+[[ -d $work ]] || git clone --quiet "$git_base$repo.git" "$work"
+cd "$work"
+git fetch --quiet --tags origin
+git -c advice.detachedHead=false checkout --quiet --detach "$ref"
+sha=$(git rev-parse HEAD)
+[[ -z $(git status --porcelain) ]] || { echo "working tree not clean" >&2; exit 1; }
+
+case $kind in
+  go)
+    name=$(jq -r .name <<<"$site")
+    [[ $name =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "bad app name: $name" >&2; exit 1; }
+    CGO_ENABLED=0 go build -trimpath -o "$apps/$name/$name.new" .
+    content=$(jq -r '.content // empty' <<<"$site")
+    if [[ -n $content ]]; then
+      # -rlt, not -a: let the folder's default ACLs set group and mode, so the
+      # app (group apps) can always read what lands there.
+      rsync -rlt --delete "$content/" "$apps/$name/content/"
+    fi
+    mv "$apps/$name/$name.new" "$apps/$name/$name"
+    sudo -n systemctl restart "goapp@$name"
+    sleep 2
+    systemctl is-active --quiet "goapp@$name" || { echo "goapp@$name is not active" >&2; exit 1; }
+    target=$name ;;
+  static)
+    host=$(jq -r .host <<<"$site")
+    dir=$(jq -r '.dir // "public"' <<<"$site")
+    rsync -rlt --delete "$dir/" "$vhosts/$host/"
+    target=$host ;;
+  *) echo "unknown kind $kind" >&2; exit 1 ;;
+esac
+
+tag="deploy/$target/$(date -u +%Y%m%dT%H%M%SZ)"
+git tag -a "$tag" -m "Deployed $sha to $target ($by)"
+git push --quiet origin "$tag"
+echo "$(date -Is) $target $sha $tag $by" >> "$logfile"
+echo "released $repo $sha as $tag"

+ 87 - 0
release/test.sh

@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# test.sh <path to a Go app repo with content/>  (e.g. ~/src/posts)
+#
+# Runs release end to end in a throwaway sandbox: a local bare remote
+# instead of Gogs, and stand-in sudo/systemctl that record what they were
+# asked to do. Nothing on the real server is touched.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+src=$(cd "${1:?usage: test.sh <go app repo>}" && pwd)
+T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
+pass=0; fail=0
+ok()  { echo "ok   $*"; pass=$((pass+1)); }
+bad() { echo "FAIL $*"; fail=$((fail+1)); }
+check() { local what=$1; shift; if "$@"; then ok "$what"; else bad "$what"; fi; }
+
+# Remote: bajdev/posts (go app with content) and bajdev/site (static).
+mkdir -p "$T/remote/bajdev" "$T/apps/posts/content" "$T/vhosts/site.example" "$T/bin"
+git clone --quiet --bare "$src" "$T/remote/bajdev/posts.git"
+git init --quiet -b main "$T/site" && mkdir "$T/site/public" && echo hi > "$T/site/public/index.html"
+git -C "$T/site" add . && git -C "$T/site" -c user.name=t -c user.email=t@t commit --quiet -m init
+git clone --quiet --bare "$T/site" "$T/remote/bajdev/site.git"
+echo stale > "$T/apps/posts/content/removed-post.md"
+
+cat > "$T/sites.json" <<EOF
+{
+  "bajdev/posts": { "kind": "go", "name": "posts", "content": "content" },
+  "bajdev/site":  { "kind": "static", "host": "site.example" }
+}
+EOF
+
+# Stand-ins: sudo just runs the command; systemctl logs and reports active
+# unless SYSTEMCTL_FAIL is set.
+printf '#!/bin/sh\n[ "$1" = -n ] && shift\nexec "$@"\n' > "$T/bin/sudo"
+printf '#!/bin/sh\necho "$*" >> "%s/systemctl.log"\n[ "$1" = is-active ] && [ -n "$SYSTEMCTL_FAIL" ] && exit 3\nexit 0\n' "$T" > "$T/bin/systemctl"
+chmod +x "$T/bin/sudo" "$T/bin/systemctl"
+
+export PATH="$T/bin:$PATH" RELEASE_CONF="$T/sites.json" RELEASE_GIT_BASE="$T/remote/" \
+  RELEASE_WORK="$T/work" RELEASE_APPS="$T/apps" RELEASE_VHOSTS="$T/vhosts" RELEASE_LOG="$T/deploys.log" \
+  GIT_AUTHOR_NAME=deploy GIT_AUTHOR_EMAIL=deploy@test GIT_COMMITTER_NAME=deploy GIT_COMMITTER_EMAIL=deploy@test
+R="$here/release"
+head=$(git -C "$src" rev-parse main)
+
+# 1. A normal release of the Go app.
+out=$(DEPLOY_BY="PR merged by bajdev" "$R" bajdev/posts "$head" 2>&1) || { bad "release posts: $out"; }
+check "binary built"              test -x "$T/apps/posts/posts"
+check "no .new left behind"       test ! -e "$T/apps/posts/posts.new"
+check "content synced"            test -f "$T/apps/posts/content/2026-10-24-hello-world.md"
+check "deleted posts removed"     test ! -e "$T/apps/posts/content/removed-post.md"
+check "service restarted"         grep -qx "restart goapp@posts" "$T/systemctl.log"
+tag1=$(git -C "$T/remote/bajdev/posts.git" tag -l 'deploy/posts/*' | tail -1)
+check "deploy tag pushed"         test -n "$tag1"
+check "tag is on the commit"      test "$(git -C "$T/remote/bajdev/posts.git" rev-parse "$tag1^{commit}")" = "$head"
+check "log line written"          grep -q "posts $head $tag1 PR merged by bajdev" "$T/deploys.log"
+
+# 2. Rollback to that tag (a second later, so the new tag name differs).
+sleep 1
+out=$(DEPLOY_BY="rollback by bajdev" "$R" bajdev/posts "$tag1" 2>&1) || bad "rollback: $out"
+check "rollback logged"           grep -q "rollback by bajdev" "$T/deploys.log"
+check "rollback tagged"           test "$(git -C "$T/remote/bajdev/posts.git" tag -l 'deploy/posts/*' | wc -l)" -eq 2
+
+# 3. Static site.
+out=$("$R" bajdev/site main 2>&1) || bad "release site: $out"
+check "static files synced"       test -f "$T/vhosts/site.example/index.html"
+check "static tag pushed"         test -n "$(git -C "$T/remote/bajdev/site.git" tag -l 'deploy/site.example/*')"
+
+# 4. Refusals.
+refused() { # <message the right guard prints> <args...>
+  local want=$1; shift; local out
+  if out=$("$R" "$@" 2>&1); then return 1; fi
+  [[ $out == *"$want"* ]]
+}
+check "unknown repo refused"      refused "unknown repo" bajdev/nope main
+check "../ repo name refused"     refused "bad repo name" ../x main
+check "dot-leading name refused"  refused "bad repo name" bajdev/.git main
+check "option-like ref refused"   refused "bad ref" bajdev/posts --upload-pack=x
+check "wrong arg count refused"   refused "usage:" bajdev/posts
+
+# 5. A service that fails to start: no tag, no log line.
+tags_before=$(git -C "$T/remote/bajdev/posts.git" tag -l 'deploy/posts/*' | wc -l)
+lines_before=$(wc -l < "$T/deploys.log")
+sleep 1
+check "failed start exits non-zero" bash -c '! SYSTEMCTL_FAIL=1 "$0" bajdev/posts main >/dev/null 2>&1' "$R"
+check "failed start not tagged"   test "$(git -C "$T/remote/bajdev/posts.git" tag -l 'deploy/posts/*' | wc -l)" -eq "$tags_before"
+check "failed start not logged"   test "$(wc -l < "$T/deploys.log")" -eq "$lines_before"
+
+echo "$pass passed, $fail failed"
+[[ $fail -eq 0 ]]

+ 3 - 0
sites.json

@@ -0,0 +1,3 @@
+{
+  "bajdev/posts": { "kind": "go", "name": "posts", "content": "content" }
+}

+ 3 - 0
sudoers/60-deploy

@@ -0,0 +1,3 @@
+# deploy user: restart Go apps after a release, nothing else.
+# The ^...$ pattern must match the whole argument string (sudo >= 1.9.10).
+deploy ALL=(root) NOPASSWD: /usr/bin/systemctl ^restart goapp@[a-z0-9][a-z0-9-]*$

+ 13 - 0
systemd/deployd.service

@@ -0,0 +1,13 @@
+[Unit]
+Description=Gogs deploy webhook receiver
+After=network.target gogs.service
+
+[Service]
+User=deploy
+EnvironmentFile=/etc/deployd/secret.env
+Environment=HOME=/var/lib/deploy PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/local/bin/deployd
+Restart=always
+
+[Install]
+WantedBy=multi-user.target