Scaffold Matrix + GitOps + Claude automation stack
build-agent / build-and-push (push) Failing after 18s

This commit is contained in:
2026-08-23 10:25:57 +00:00
commit 357791d6e3
12 changed files with 576 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Copy to .env and fill in. Never commit the real .env.
# --- domain / TLS ---
ACME_EMAIL=you@example.com
MATRIX_SERVER_NAME=matrix.apps.williamturner.eu
AGENT_HOSTNAME=agent.apps.williamturner.eu
# Set to true ONLY for the first-boot window while creating the bot account,
# then back to false (or unset) and redeploy. See README.
MATRIX_ALLOW_REGISTRATION=false
# --- gitea ---
GITEA_URL=https://gitea.apps.williamturner.eu
GITEA_TOKEN=
GITEA_WEBHOOK_SECRET=
# Image the agent runs from — built and pushed by .gitea/workflows/build.yml
GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu/<your-gitea-username>/claude-agent:latest
# --- anthropic ---
ANTHROPIC_API_KEY=
# --- matrix bot ---
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
MATRIX_BOT_TOKEN=
MATRIX_CONTROL_ROOM_ID=
# --- portainer (GitOps redeploy) ---
PORTAINER_STACK_WEBHOOK_URL=
# --- gitea actions runner ---
ACT_RUNNER_REGISTRATION_TOKEN=
+26
View File
@@ -0,0 +1,26 @@
name: build-agent
on:
push:
branches: [main]
paths:
- "agent/**"
- ".gitea/workflows/build.yml"
jobs:
build-and-push:
# If your act_runner advertises a different label than "docker" (check its
# config.yaml / GITEA_RUNNER_LABELS), update this to match.
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea registry
run: |
echo "${{ secrets.GITEA_TOKEN }}" | docker login "${{ vars.GITEA_HOST }}" \
-u "${{ gitea.actor }}" --password-stdin
- name: Build and push claude-agent image
run: |
IMAGE="${{ vars.GITEA_HOST }}/${{ gitea.repository }}/claude-agent:latest"
docker build -t "$IMAGE" ./agent
docker push "$IMAGE"
+2
View File
@@ -0,0 +1,2 @@
.env
node_modules/
+109
View File
@@ -0,0 +1,109 @@
# gitops-automation
Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See
`~/.claude/plans/cozy-honking-lantern.md` on the host for the full design rationale.
Three things this gives you:
- **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored
review comment.
- **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image
(Gitea Actions) and redeploys the stack (Portainer webhook).
- **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
room clones the repo, runs Claude Code, and opens a PR with the result.
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
## Prerequisites (one-time, on the VPS)
```bash
sudo usermod -aG docker william # then start a new shell/session
docker network create web
```
## Bring-up order
1. **DNS** — confirm these resolve to `217.160.66.143`:
`gitea.apps.williamturner.eu`, `portainer.apps.williamturner.eu`,
`matrix.apps.williamturner.eu`, `agent.apps.williamturner.eu`.
2. **Front Gitea with Traefik**: edit `~/gitea/docker-compose.yml` — add it to the `web`
network and Traefik labels (mirror the `matrix-homeserver` block in this repo's
`docker-compose.yml`, using port `3000` as the service port and rule
`` Host(`gitea.apps.williamturner.eu`) ``). Update `~/gitea/data/gitea/conf/app.ini`:
`DOMAIN` and `ROOT_URL` → `https://gitea.apps.williamturner.eu/`. Recreate the container,
confirm the HTTPS URL works, *then* remove `3000:3000` from the port mapping.
3. **Front Portainer with Traefik**: same pattern on `~/portainer-compose.yaml`, service
port `9443` (Portainer serves TLS itself on that port — either terminate TLS at Traefik
with `traefik.http.services.portainer.loadbalancer.server.scheme=https` and
`serversTransport` with insecure skip-verify, or simplest: also expose Portainer's plain
HTTP port internally and point Traefik at that instead). Confirm
`https://portainer.apps.williamturner.eu` works before removing `9443:9443`.
4. **Create the `gitops-automation` repo in Gitea** (via `http://217.160.66.143:3000` if
step 2 isn't done yet, otherwise the HTTPS URL), push this directory to it.
5. **Gitea API token**: user Settings → Applications → generate a token with repo +
webhook scopes. Put it in `.env` as `GITEA_TOKEN`.
6. **Gitea Actions runner**: admin Settings → Actions → Runners → create registration
token → `.env` as `ACT_RUNNER_REGISTRATION_TOKEN`. Also set repo-level Actions
variables `GITEA_HOST` (e.g. `gitea.apps.williamturner.eu`) and secret `GITEA_TOKEN`
(Settings → Actions → Variables/Secrets on the repo) — the workflow in
`.gitea/workflows/build.yml` reads those.
7. **First image build** (registry is empty until Actions runs once):
```bash
cp .env.example .env # fill in values as you go
docker build -t "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)" ./agent
docker login <gitea-host> -u <your-username>
docker push "$(grep GITEA_REGISTRY_IMAGE .env | cut -d= -f2)"
```
8. **Bring up the stack**, ideally as a Portainer "Repository" stack pointed at this repo
(so it's also the GitOps redeploy target) — or directly:
```bash
docker compose up -d
```
9. **First boot: Matrix bot account** — with `MATRIX_ALLOW_REGISTRATION=true` in `.env`,
redeploy `matrix-homeserver`, then register the bot:
```bash
curl -s https://matrix.apps.williamturner.eu/_matrix/client/v3/register \
-H 'Content-Type: application/json' \
-d '{"username":"claude-bot","password":"<pick one>","auth":{"type":"m.login.dummy"}}'
```
This returns an `access_token` — put it in `.env` as `MATRIX_BOT_TOKEN`. Then set
`MATRIX_ALLOW_REGISTRATION=false` and redeploy `matrix-homeserver` again.
10. **Control room**: from any Matrix client logged in as yourself on this homeserver,
create a room, invite `@claude-bot:matrix.apps.williamturner.eu`, copy the room ID
into `.env` as `MATRIX_CONTROL_ROOM_ID`. Redeploy `claude-agent`.
11. **Portainer stack webhook**: in the stack's settings, enable the webhook, copy the URL
into `.env`/repo secrets as `PORTAINER_STACK_WEBHOOK_URL`.
12. **Gitea webhooks** on each repo you want automation for:
- push → `main` → `PORTAINER_STACK_WEBHOOK_URL` (only needed on *this* repo, for
GitOps redeploy of the automation stack itself)
- pull request (opened, synchronized) → `https://agent.apps.williamturner.eu/webhooks/gitea`,
secret = `GITEA_WEBHOOK_SECRET`, on every repo you want auto-reviewed.
13. **Firewall**: `sudo ufw allow 80/tcp 443/tcp`; once the HTTPS routes above are all
confirmed working, `sudo ufw delete allow 3000/tcp` and `sudo ufw delete allow 9443/tcp`.
## Smoke test
- Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment.
- In the Matrix control room: `!claude owner/repo add a comment to the README` → expect a
"working on it" reply, then a PR link.
- `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy.
## Notes
- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git
checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`),
so even a misbehaving prompt can't push directly or touch `main`.
- The Matrix bot only reacts inside `MATRIX_CONTROL_ROOM_ID`; keep that room invite-only.
- `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check
`GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered.
+2
View File
@@ -0,0 +1,2 @@
node_modules
.env
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
openssh-client \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY src ./src
RUN git config --global user.email "claude-agent@apps.williamturner.eu" \
&& git config --global user.name "claude-agent" \
&& git config --global --add safe.directory '*'
ENV NODE_ENV=production
EXPOSE 3001
CMD ["node", "src/server.js"]
+14
View File
@@ -0,0 +1,14 @@
{
"name": "claude-gitops-agent",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"express": "^4.19.2",
"matrix-bot-sdk": "^0.7.1"
}
}
+54
View File
@@ -0,0 +1,54 @@
const GITEA_URL = process.env.GITEA_URL;
const GITEA_TOKEN = process.env.GITEA_TOKEN;
function authHeaders() {
return {
Authorization: `token ${GITEA_TOKEN}`,
"Content-Type": "application/json",
};
}
async function assertOk(res, action) {
if (!res.ok) {
throw new Error(`${action} failed: ${res.status} ${await res.text()}`);
}
}
export async function postPRComment(owner, repo, index, body) {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/issues/${index}/comments`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ body }),
});
await assertOk(res, "post PR comment");
}
export async function createBranch(owner, repo, newBranch, oldBranch = "main") {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }),
});
await assertOk(res, "create branch");
}
export async function createPullRequest(owner, repo, { head, base = "main", title, body }) {
const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`;
const res = await fetch(url, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ head, base, title, body }),
});
await assertOk(res, "create PR");
return res.json();
}
// Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys.
export function authenticatedCloneUrl(cloneUrl) {
const u = new URL(cloneUrl);
u.username = "claude-agent";
u.password = GITEA_TOKEN;
return u.toString();
}
+55
View File
@@ -0,0 +1,55 @@
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
import { runChatTask } from "./runner.js";
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
const GITEA_URL = process.env.GITEA_URL;
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
function parseCommand(text) {
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
if (!match) return null;
const [, repoFullName, instruction] = match;
return { repoFullName, instruction: instruction.trim() };
}
export async function startMatrixBot() {
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
console.warn("Matrix env vars not set — skipping bot startup");
return;
}
const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
AutojoinRoomsMixin.setupOnClient(client);
const selfUserId = await client.getUserId();
client.on("room.message", async (roomId, event) => {
// Invite-only control room enforces who can reach the bot at all; this just
// scopes command handling to that one room and ignores the bot's own messages.
if (roomId !== CONTROL_ROOM_ID) return;
if (event.sender === selfUserId) return;
const body = event.content?.body;
if (!body) return;
const cmd = parseCommand(body);
if (!cmd) return;
const [owner, repo] = cmd.repoFullName.split("/");
await client.sendText(roomId, `Working on it: ${cmd.repoFullName}${cmd.instruction}`);
try {
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction });
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
} catch (err) {
console.error("chat task failed", err);
await client.sendText(roomId, `Failed: ${err.message}`);
}
});
await client.start();
console.log("Matrix bot started, room:", CONTROL_ROOM_ID);
}
+99
View File
@@ -0,0 +1,99 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js";
const execFileAsync = promisify(execFile);
const WORKSPACE_ROOT = "/workspace";
async function run(cmd, args, opts = {}) {
const { stdout } = await execFileAsync(cmd, args, {
maxBuffer: 1024 * 1024 * 32,
...opts,
});
return stdout;
}
async function withWorkspace(fn) {
await mkdir(WORKSPACE_ROOT, { recursive: true });
const dir = await mkdtemp(path.join(WORKSPACE_ROOT, "job-"));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// Runs Claude Code headless. Unattended containers have no TTY to answer permission
// prompts, so this trusts the sandboxing of the throwaway clone dir instead:
// bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so
// Claude can never push/commit/checkout itself — this script owns those steps.
async function runClaude(cwd, prompt, { allowEdits }) {
const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"];
if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit");
const args = [
"-p", prompt,
"--output-format", "text",
"--permission-mode", "bypassPermissions",
"--disallowedTools", disallowed.join(","),
];
return run("claude", args, { cwd });
}
export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, prBody }) {
return withWorkspace(async (dir) => {
const authedUrl = authenticatedCloneUrl(cloneUrl);
await run("git", ["clone", "--quiet", authedUrl, dir]);
await run("git", ["fetch", "--quiet", "origin", ref], { cwd: dir });
await run("git", ["checkout", "--quiet", ref], { cwd: dir });
const prompt = [
"You are reviewing a pull request. Diff HEAD against the base branch (origin/main)",
"and give a concise, specific code review: correctness bugs first, then",
"simplification/reuse/efficiency notes. Read-only — do not edit any files.",
`PR title: ${prTitle}`,
`PR description:\n${prBody}`,
].join("\n");
return runClaude(dir, prompt, { allowEdits: false });
});
}
export async function runChatTask({ owner, repo, cloneUrl, instruction }) {
return withWorkspace(async (dir) => {
const authedUrl = authenticatedCloneUrl(cloneUrl);
await run("git", ["clone", "--quiet", authedUrl, dir]);
const branch = `claude/${crypto.randomBytes(4).toString("hex")}`;
await createBranch(owner, repo, branch);
await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir });
await run("git", ["checkout", "--quiet", branch], { cwd: dir });
const prompt = [
"Implement the change described below in this repository. Make the smallest",
"correct change that satisfies it. Do not run git commit, git push, or git",
"checkout yourself — just edit files; committing and pushing happens separately.",
`Instruction: ${instruction}`,
].join("\n");
await runClaude(dir, prompt, { allowEdits: true });
await run("git", ["add", "-A"], { cwd: dir });
const status = await run("git", ["status", "--porcelain"], { cwd: dir });
if (!status.trim()) {
throw new Error("Claude made no changes for this instruction");
}
await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir });
await run("git", ["push", "--quiet", "origin", branch], { cwd: dir });
return createPullRequest(owner, repo, {
head: branch,
title: `claude: ${instruction}`.slice(0, 200),
body: `Requested via Matrix:\n\n> ${instruction}`,
});
});
}
+66
View File
@@ -0,0 +1,66 @@
import express from "express";
import crypto from "node:crypto";
import { postPRComment } from "./gitea.js";
import { reviewPullRequest } from "./runner.js";
import { startMatrixBot } from "./matrixBot.js";
const app = express();
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
const PORT = process.env.PORT || 3001;
const WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET;
function verifySignature(req) {
if (!WEBHOOK_SECRET) return false;
const sig = req.get("X-Gitea-Signature");
if (!sig) return false;
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.rawBody).digest("hex");
const sigBuf = Buffer.from(sig, "hex");
const expBuf = Buffer.from(expected, "hex");
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
}
app.get("/healthz", (_req, res) => res.send("ok"));
app.post("/webhooks/gitea", async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send("bad signature");
}
// Ack immediately — Gitea has a short webhook timeout and the review itself takes a while.
res.status(202).send("accepted");
const event = req.get("X-Gitea-Event");
const body = req.body;
try {
if (event === "pull_request" && ["opened", "synchronize"].includes(body.action)) {
const { repository, pull_request: pr } = body;
const [owner, repo] = repository.full_name.split("/");
const review = await reviewPullRequest({
owner,
repo,
ref: pr.head.sha,
cloneUrl: repository.clone_url,
prTitle: pr.title,
prBody: pr.body || "",
});
await postPRComment(owner, repo, pr.number, review);
}
} catch (err) {
console.error("webhook handling failed:", err);
}
});
app.listen(PORT, () => {
console.log(`claude-agent listening on :${PORT}`);
});
startMatrixBot().catch((err) => {
console.error("matrix bot failed to start:", err);
});
+96
View File
@@ -0,0 +1,96 @@
services:
traefik:
image: traefik:v3.1
container_name: traefik
restart: unless-stopped
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=web"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--log.level=INFO"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "traefik_letsencrypt:/letsencrypt"
networks:
- web
# Routes for services that live in OTHER compose files (Gitea, Portainer) are added
# as labels on those containers directly, not here — see README "Fronting existing
# services" section.
matrix-homeserver:
image: ghcr.io/continuwuity/continuwuity:latest
container_name: matrix-homeserver
restart: unless-stopped
environment:
CONTINUWUITY_SERVER_NAME: ${MATRIX_SERVER_NAME}
CONTINUWUITY_DATABASE_PATH: /var/lib/continuwuity
CONTINUWUITY_ADDRESS: 0.0.0.0
CONTINUWUITY_PORT: 8008
# Private control-room bot only — no federation, no open registration.
# Registration is flipped on temporarily, once, to create the bot account
# (see README "First boot: Matrix bot account").
CONTINUWUITY_ALLOW_FEDERATION: "false"
CONTINUWUITY_ALLOW_REGISTRATION: ${MATRIX_ALLOW_REGISTRATION:-false}
volumes:
- matrix_data:/var/lib/continuwuity
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.matrix.rule=Host(`${MATRIX_SERVER_NAME}`)"
- "traefik.http.routers.matrix.entrypoints=websecure"
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
- "traefik.http.services.matrix.loadbalancer.server.port=8008"
claude-agent:
image: ${GITEA_REGISTRY_IMAGE}
container_name: claude-agent
restart: unless-stopped
env_file: .env
volumes:
- agent_workspace:/workspace
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.agent.rule=Host(`${AGENT_HOSTNAME}`)"
- "traefik.http.routers.agent.entrypoints=websecure"
- "traefik.http.routers.agent.tls.certresolver=letsencrypt"
- "traefik.http.services.agent.loadbalancer.server.port=3001"
act_runner:
image: gitea/act_runner:latest
container_name: act_runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: ${GITEA_URL}
GITEA_RUNNER_REGISTRATION_TOKEN: ${ACT_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: gitops-vps-runner
GITEA_RUNNER_LABELS: docker:docker://node:22-bookworm
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- act_runner_data:/data
networks:
- web
networks:
web:
external: true
volumes:
traefik_letsencrypt:
matrix_data:
agent_workspace:
act_runner_data: