feat(yolo): run rev from nix, deploy both apps by webhook
rev came from a working checkout: the unit ran `node server/index.ts` in ~/tea/rev, so any edit there was live, and rev-deploy rebuilt a prod directory that no longer exists. It is a flake input now, same as maestro, and `services.rev` owns the unit. Adds nix-autodeploy: one endpoint both repos post to. It verifies the webhook HMAC, bumps that flake input, commits and pushes flake.lock, then switches. The switch runs in a transient unit, since it restarts the listener itself. maestro is bump-and-notify only — switching would restart the daemon that owns every interactive shell here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a3ccd2442
commit
c99627b3f6
Generated
+21
@@ -1052,6 +1052,26 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"rev": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787647619,
|
||||
"narHash": "sha256-2uifZrVfZxca+jMfAD92caqlRf4ME3SIbJtv43f7Vsk=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "6750a1871521b95fe177abfae5a9f3ef60f23f2c",
|
||||
"revCount": 233,
|
||||
"type": "git",
|
||||
"url": "https://git.naps.pt/yolo/rev.git"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.naps.pt/yolo/rev.git"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"agent-of-empires": "agent-of-empires",
|
||||
@@ -1069,6 +1089,7 @@
|
||||
"nixpkgs": "nixpkgs_7",
|
||||
"nvchad-starter": "nvchad-starter",
|
||||
"nvchad4nix": "nvchad4nix",
|
||||
"rev": "rev",
|
||||
"rose-pine-hyprcursor": "rose-pine-hyprcursor",
|
||||
"sem": "sem",
|
||||
"spicetify-nix": "spicetify-nix",
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
# Terminal-session orchestrator. Pins its own nixpkgs for the same reason
|
||||
# ethui and agent-of-empires do — it is a verified Rust build.
|
||||
maestro.url = "git+https://git.naps.pt/naps62/maestro.git";
|
||||
# Always-on local code review server. Follows nixpkgs, unlike the Rust
|
||||
# inputs above: it is a plain node bundle, and a second nixpkgs would put a
|
||||
# second node 26 in the closure for nothing.
|
||||
rev = {
|
||||
url = "git+https://git.naps.pt/yolo/rev.git";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nix-index-database = {
|
||||
url = "github:nix-community/nix-index-database";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# nix-autodeploy: one webhook endpoint that turns "a repo I own pushed to main"
|
||||
# into "that flake input is bumped, committed, and (optionally) applied".
|
||||
#
|
||||
# Replaces per-app deploy webhooks that rebuilt from a checkout on the box. The
|
||||
# apps are flake inputs now, so deploying one is a lock bump plus a generation
|
||||
# switch — the same operation for every app, hence one service instead of N.
|
||||
let
|
||||
cfg = config.services.nixAutodeploy;
|
||||
|
||||
# A user unit inherits almost no PATH, and the deploy shells out to git (with
|
||||
# the gitea credential helper), nix and nh.
|
||||
profilePath = lib.concatStringsSep ":" [
|
||||
"%h/.local/bin"
|
||||
"%h/.nix-profile/bin"
|
||||
"/etc/profiles/per-user/%u/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
];
|
||||
|
||||
deploy = pkgs.writeShellApplication {
|
||||
name = "nix-autodeploy-deploy";
|
||||
runtimeInputs = [
|
||||
pkgs.git
|
||||
pkgs.nix
|
||||
pkgs.nh
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
pkgs.util-linux
|
||||
];
|
||||
text = ''
|
||||
# usage: nix-autodeploy-deploy <flake-input> <apply|notify>
|
||||
input=$1
|
||||
mode=$2
|
||||
flake=${lib.escapeShellArg cfg.flake}
|
||||
topic=${lib.escapeShellArg cfg.ntfy.topic}
|
||||
ntfy_url=${lib.escapeShellArg cfg.ntfy.url}
|
||||
|
||||
notify() {
|
||||
[ -n "''${NTFY_TOKEN:-}" ] || return 0
|
||||
curl -fsS -X POST "$ntfy_url/$topic" \
|
||||
-H "Authorization: Bearer $NTFY_TOKEN" \
|
||||
-H "Title: $1" -d "$2" > /dev/null || true
|
||||
}
|
||||
|
||||
# Serialize: two pushes landing together would otherwise race on the same
|
||||
# working tree and the same flake.lock.
|
||||
exec 9> "''${XDG_RUNTIME_DIR:-/tmp}/nix-autodeploy.lock"
|
||||
flock 9
|
||||
|
||||
cd "$flake"
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$branch" != main ]; then
|
||||
notify "autodeploy skipped ($input)" "checkout is on $branch, not main"
|
||||
exit 0
|
||||
fi
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
notify "autodeploy skipped ($input)" "working tree is dirty"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git fetch --quiet origin main
|
||||
git merge --ff-only --quiet origin/main
|
||||
|
||||
nix flake update "$input"
|
||||
if git diff --quiet -- flake.lock; then
|
||||
echo "autodeploy: $input already at the pushed revision"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rev=$(nix flake metadata --json |
|
||||
jq -r --arg i "$input" '.locks.nodes[$i].locked.rev[0:7]')
|
||||
git commit --quiet -m "chore(flake): bump $input to $rev" -- flake.lock
|
||||
git push --quiet origin main
|
||||
|
||||
if [ "$mode" != apply ]; then
|
||||
notify "$input bumped to $rev" "Not applied — run 'nh home switch' when convenient."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if nh home switch "$flake"; then
|
||||
notify "$input deployed" "Bumped to $rev and switched."
|
||||
else
|
||||
notify "$input FAILED to apply" "Lock is at $rev on main; the switch failed. See journalctl --user -u run-*."
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
listener = pkgs.writers.writePython3Bin "nix-autodeploy" {
|
||||
# Only line length: http.server's do_GET/do_POST spelling is already
|
||||
# excused inline.
|
||||
flakeIgnore = [ "E501" ];
|
||||
} (builtins.readFile ./listener.py);
|
||||
|
||||
repoMap = lib.mapAttrs (_: r: {
|
||||
inherit (r) input;
|
||||
inherit (r) apply;
|
||||
}) cfg.repos;
|
||||
in
|
||||
{
|
||||
options.services.nixAutodeploy = {
|
||||
enable = lib.mkEnableOption "the forge-webhook listener that bumps and applies flake inputs";
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 7375;
|
||||
description = "Port the listener binds on 0.0.0.0. There is no auth beyond the webhook HMAC, so only expose it through the reverse proxy.";
|
||||
};
|
||||
|
||||
flake = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/home/naps62/tea/nixos-config";
|
||||
description = "Absolute path to the nixos-config checkout whose flake.lock gets bumped. Must be on main and clean, or the deploy skips.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "%h/.config/nix-autodeploy/env";
|
||||
description = ''
|
||||
File holding `NIX_AUTODEPLOY_SECRET` (the webhook HMAC secret, shared
|
||||
with every repo below) and `NTFY_TOKEN`. Not in the store — these are
|
||||
secrets.
|
||||
'';
|
||||
};
|
||||
|
||||
ntfy = {
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "https://ntfy.home.naps.pt";
|
||||
description = "Base URL of the ntfy server deploy results are posted to.";
|
||||
};
|
||||
topic = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nix-autodeploy";
|
||||
description = "ntfy topic for deploy results.";
|
||||
};
|
||||
};
|
||||
|
||||
repos = lib.mkOption {
|
||||
default = { };
|
||||
description = "Forge repositories to listen for, keyed by `<owner>/<repo>` exactly as the webhook payload spells it.";
|
||||
example = lib.literalExpression ''
|
||||
{ "yolo/rev" = { input = "rev"; }; }
|
||||
'';
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
input = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Name of the flake input in this repo's flake.nix that tracks that repository.";
|
||||
};
|
||||
apply = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Whether to run `nh home switch` after the bump. Set false for an
|
||||
app whose restart disrupts a live session — the lock is still
|
||||
bumped and pushed, and the ntfy message says it is waiting.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
systemd.user.services.nix-autodeploy = {
|
||||
Unit = {
|
||||
Description = "nix-autodeploy — forge webhooks bump and apply flake inputs";
|
||||
After = [ "network.target" ];
|
||||
# Same restart-budget trap every other always-on unit here avoids: at
|
||||
# RestartSec=2 a fast-crashing listener would park in `failed`.
|
||||
StartLimitIntervalSec = 0;
|
||||
};
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart = lib.getExe listener;
|
||||
EnvironmentFile = cfg.environmentFile;
|
||||
Environment = [
|
||||
"PATH=${profilePath}"
|
||||
"NIX_AUTODEPLOY_PORT=${toString cfg.port}"
|
||||
"NIX_AUTODEPLOY_DEPLOY_BIN=${lib.getExe deploy}"
|
||||
"NIX_AUTODEPLOY_ENV_FILE=${cfg.environmentFile}"
|
||||
"NIX_AUTODEPLOY_REPOS=${builtins.toJSON repoMap}"
|
||||
];
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
};
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Forge webhook listener that turns a push into a flake-input bump.
|
||||
|
||||
Verifies the HMAC a Gitea (or GitHub) webhook signs the body with, then hands
|
||||
the actual work to a transient systemd unit. Nothing is done in-process: the
|
||||
deploy runs `nh home switch`, which restarts every unit home-manager owns —
|
||||
including this listener — so the job has to outlive it.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
SECRET = os.environ.get("NIX_AUTODEPLOY_SECRET", "").encode()
|
||||
PORT = int(os.environ.get("NIX_AUTODEPLOY_PORT", "7375"))
|
||||
DEPLOY = os.environ["NIX_AUTODEPLOY_DEPLOY_BIN"]
|
||||
# {"<owner>/<repo>": {"input": "rev", "apply": true}, ...}
|
||||
REPOS = json.loads(os.environ["NIX_AUTODEPLOY_REPOS"])
|
||||
ENV_FILE = os.environ.get("NIX_AUTODEPLOY_ENV_FILE", "")
|
||||
MAX_BODY = 1 << 20
|
||||
|
||||
if not SECRET:
|
||||
sys.exit("NIX_AUTODEPLOY_SECRET is not set")
|
||||
|
||||
|
||||
def signature_ok(body: bytes, headers) -> bool:
|
||||
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
||||
# Gitea sends the bare hex digest; GitHub prefixes it with "sha256=".
|
||||
for name in ("X-Gitea-Signature", "X-Hub-Signature-256"):
|
||||
got = headers.get(name)
|
||||
if got and hmac.compare_digest(got.removeprefix("sha256="), expected):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def spawn(repo: str, entry: dict) -> None:
|
||||
cmd = [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--collect",
|
||||
f"--description=nix-autodeploy: {repo}",
|
||||
# PATH is not inherited by a transient unit, and the deploy shells out
|
||||
# to git, nix and the gitea credential helper.
|
||||
f"--setenv=PATH={os.environ['PATH']}",
|
||||
]
|
||||
if ENV_FILE:
|
||||
cmd.append(f"--property=EnvironmentFile={ENV_FILE}")
|
||||
cmd += [DEPLOY, entry["input"], "apply" if entry.get("apply", True) else "notify"]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def reply(self, code: int, text: str) -> None:
|
||||
payload = text.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's spelling
|
||||
if self.path == "/health":
|
||||
self.reply(200, "ok\n")
|
||||
else:
|
||||
self.reply(404, "no\n")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length > MAX_BODY:
|
||||
return self.reply(413, "body too large\n")
|
||||
body = self.rfile.read(length)
|
||||
|
||||
if not signature_ok(body, self.headers):
|
||||
return self.reply(401, "bad signature\n")
|
||||
|
||||
try:
|
||||
event = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return self.reply(400, "bad json\n")
|
||||
|
||||
ref = event.get("ref")
|
||||
repo = (event.get("repository") or {}).get("full_name")
|
||||
if ref != "refs/heads/main":
|
||||
return self.reply(200, f"ignored ref {ref}\n")
|
||||
|
||||
entry = REPOS.get(repo)
|
||||
if entry is None:
|
||||
return self.reply(200, f"ignored repo {repo}\n")
|
||||
|
||||
spawn(repo, entry)
|
||||
self.reply(202, f"deploying {entry['input']}\n")
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
# Journal already timestamps; the default format prepends its own.
|
||||
sys.stderr.write(f"{self.address_string()} {fmt % args}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,7 @@
|
||||
{ inputs, ... }:
|
||||
# rev: the always-on code review server. Module only — no `enable` here. It
|
||||
# discovers every repo under its roots and serves them without auth, so only a
|
||||
# box that is already a trusted single-user machine should run it.
|
||||
{
|
||||
imports = [ inputs.rev.homeManagerModules.default ];
|
||||
}
|
||||
@@ -22,6 +22,8 @@ in
|
||||
../common/programs/gpg.nix
|
||||
../common/programs/aoe
|
||||
../common/programs/maestro
|
||||
../common/programs/rev
|
||||
../common/programs/nix-autodeploy
|
||||
../common/features/xdg.nix
|
||||
./monitors.nix
|
||||
./services.nix
|
||||
|
||||
+38
-58
@@ -3,12 +3,11 @@
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
# The user services this box exists to run, ported from hand-written units in
|
||||
# ~/.config/systemd/user on the Ubuntu machine.
|
||||
# The user services this box exists to run.
|
||||
#
|
||||
# NOT self-contained: every ExecStart under ~/.bun or ~/.local/bin is an
|
||||
# imperatively-installed binary, and the WorkingDirectories are clones of
|
||||
# separate repos. Nix owns the unit definitions here, nothing more.
|
||||
# maestro and rev come from their own flakes, so nix owns the build as well as
|
||||
# the unit. aoe-web is still the odd one out: its unit is defined here and the
|
||||
# binary comes from the flake input.
|
||||
let
|
||||
# A user unit gets almost no PATH by default; these are the profile dirs the
|
||||
# original units got for free from the system PATH on Ubuntu.
|
||||
@@ -19,16 +18,17 @@ let
|
||||
aoe = inputs.agent-of-empires.packages.${pkgs.system}.aoe-with-web;
|
||||
in
|
||||
{
|
||||
# sem is here as well as on rev's unit: the shell uses it directly too.
|
||||
home.packages = [
|
||||
sem
|
||||
pkgs.bun
|
||||
# ACP adapter aoe's structured (web) sessions spawn as `claude-agent-acp`.
|
||||
pkgs.claude-agent-acp
|
||||
];
|
||||
|
||||
# maestro's own units come from its flake module, not from the hand-written
|
||||
# set below. 8081, not 8080: aoe-web already has that port on this host.
|
||||
# There is no auth layer, so 0.0.0.0 is only safe behind the LAN perimeter.
|
||||
# maestro's and rev's units come from their flake modules, not from the
|
||||
# hand-written set below. 8081, not 8080: aoe-web already has that port on
|
||||
# this host. There is no auth layer, so 0.0.0.0 is only safe behind the LAN
|
||||
# perimeter.
|
||||
services.maestro = {
|
||||
enable = true;
|
||||
web.enable = true;
|
||||
@@ -41,60 +41,40 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Everything under ~, three levels deep — the worktrees live at
|
||||
# ~/<area>/<repo>/worktrees/<name>. sem gives entity-level diffs; without it
|
||||
# rev falls back to line diffs.
|
||||
services.rev = {
|
||||
enable = true;
|
||||
roots = [ "%h" ];
|
||||
depth = 3;
|
||||
semBin = "${sem}/bin/sem";
|
||||
};
|
||||
|
||||
# One endpoint for both app repos. rev applies straight away; maestro only
|
||||
# bumps the lock and notifies, because switching restarts the daemon that
|
||||
# owns every interactive shell on this box.
|
||||
services.nixAutodeploy = {
|
||||
enable = true;
|
||||
flake = "/home/naps62/tea/nixos-config";
|
||||
environmentFile = "%h/.config/nix-autodeploy/env";
|
||||
repos = {
|
||||
"yolo/rev".input = "rev";
|
||||
"naps62/maestro" = {
|
||||
input = "maestro";
|
||||
apply = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemd.user.services = {
|
||||
rev = {
|
||||
Unit = {
|
||||
Description = "rev — always-on local code review server";
|
||||
After = [ "network.target" ];
|
||||
# MUST stay 0: at RestartSec=2 a fast-crashing rev burns the default
|
||||
# 5-starts-per-10s budget, and systemd parks the unit in `failed` until
|
||||
# a manual `systemctl --user reset-failed`.
|
||||
StartLimitIntervalSec = 0;
|
||||
};
|
||||
Service = {
|
||||
Type = "simple";
|
||||
WorkingDirectory = "%h/tea/rev";
|
||||
# nodejs_26, not pkgs.nodejs: rev's package.json sets engines >=26 and
|
||||
# the nixpkgs default is 24.
|
||||
ExecStart = "${pkgs.nodejs_26}/bin/node server/index.ts";
|
||||
Environment = [
|
||||
"NODE_ENV=production"
|
||||
"REV_ROOTS=%h"
|
||||
"REV_DEPTH=3"
|
||||
"REV_SEM_BIN=${sem}/bin/sem"
|
||||
"PATH=${toolPath}"
|
||||
];
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
};
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
};
|
||||
|
||||
rev-deploy = {
|
||||
Unit = {
|
||||
Description = "rev-deploy — Gitea webhook listener that deploys rev on push to main";
|
||||
After = [ "network.target" ];
|
||||
# Same restart-budget trap as `rev` above.
|
||||
StartLimitIntervalSec = 0;
|
||||
};
|
||||
Service = {
|
||||
Type = "simple";
|
||||
WorkingDirectory = "%h/tea/rev";
|
||||
ExecStart = "${pkgs.bun}/bin/bun scripts/deploy-webhook.ts";
|
||||
EnvironmentFile = "%h/.config/rev/deploy.env";
|
||||
# Unit files are home-manager symlinks; deploy.sh must not rewrite them.
|
||||
Environment = [ "PATH=${toolPath}" "REV_SKIP_UNIT_INSTALL=1" ];
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
};
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
};
|
||||
|
||||
aoe-web = {
|
||||
Unit = {
|
||||
Description = "aoe serve — Agent of Empires web dashboard";
|
||||
After = [ "network.target" ];
|
||||
# Same restart-budget trap as `rev` above.
|
||||
# MUST stay 0: at RestartSec=2 a fast-crashing aoe burns the default
|
||||
# 5-starts-per-10s budget, and systemd parks the unit in `failed` until
|
||||
# a manual `systemctl --user reset-failed`.
|
||||
StartLimitIntervalSec = 0;
|
||||
};
|
||||
Service = {
|
||||
|
||||
Reference in New Issue
Block a user