Commit 577de508ed44

Vincent Demeester <vincent@sbr.pm>
2026-02-02 15:24:22
feat(flake-updater): add daily auto-merge for chick-group and chapeau-rouge
- Refactor nix-flake-updater module to support multiple instances - Add AUTO_MERGE mode: rebases and pushes to main on successful build - Add FLAKE_INPUTS: selective input updates - Add failure handling: creates TODOs in ~/desktop/org/inbox.org - Configure two instances on aomi: - weekly: all inputs, manual review (Sunday 2 AM) - daily: chick-group + chapeau-rouge, auto-merge (daily 4 AM) - Daily updates test fewer systems (aomi, kyushu) for faster execution
1 parent fab7d65
Changed files (4)
dots
modules
nix-flake-updater
systems
tools
nix-flake-update
dots/Makefile
@@ -53,11 +53,13 @@ lazypr : ~/.config/lazypr/config.toml
 all += gh-news
 gh-news : ~/.config/gh-news/config.toml
 
-all += git-template copilot-hooks opencode-plugin pi-agent
+all += git-template copilot-hooks opencode-plugin pi-agent agent-skills agent-skill-manager
 git-template : ~/.config/git/template
 copilot-hooks : ~/.config/copilot-hooks
 opencode-plugin : ~/.config/opencode/plugin
 pi-agent : ~/.pi/agent/extensions ~/.pi/agent/AGENTS.md ~/.pi/agent/README.md
+agent-skills : ~/.config/agent-skills
+agent-skill-manager : ~/bin/agent-skill-manager
 
 # Backward compatibility: symlink ~/.claude to ~/.config/claude
 ~/.claude : force
modules/nix-flake-updater/default.nix
@@ -10,115 +10,154 @@ with lib;
 let
   cfg = config.services.nix-flake-updater;
 
-  # Use the nix-flake-update package
-  updateScript = pkgs.writeShellScript "nix-flake-update-wrapper" ''
-    export REPO_PATH="${cfg.repoPath}"
-    export FLAKE_PATH="${cfg.flakePath}"
-    export GIT_REMOTE="${cfg.gitRemote}"
-    export BRANCH_PREFIX="${cfg.branchPrefix}"
-    export NTFY_TOPIC="${cfg.ntfyTopic}"
-    export NTFY_SERVER="${cfg.ntfyServer}"
-    export BUILD_SYSTEMS="${toString cfg.buildSystems}"
-    export DRY_RUN="${toString cfg.dryRun}"
-    ${optionalString (cfg.ntfyTokenFile != null) ''export NTFY_TOKEN_FILE="${cfg.ntfyTokenFile}"''}
+  instanceOpts =
+    { config, ... }:
+    {
+      options = {
+        enable = mkEnableOption "this flake updater instance";
 
-    # Execute the packaged update script (already has tools in PATH)
-    exec ${pkgs.nix-flake-update}/bin/nix-flake-update
-  '';
+        repoPath = mkOption {
+          type = types.str;
+          example = "/home/user/nixos-config";
+          description = "Path to the git repository containing the flake";
+        };
 
-in
-{
-  options.services.nix-flake-updater = {
-    enable = mkEnableOption "automated Nix flake.lock updates";
+        flakePath = mkOption {
+          type = types.str;
+          default = config.repoPath;
+          example = "/home/user/nixos-config";
+          description = "Path to the flake (usually same as repoPath)";
+        };
 
-    repoPath = mkOption {
-      type = types.str;
-      example = "/home/user/nixos-config";
-      description = "Path to the git repository containing the flake";
+        gitRemote = mkOption {
+          type = types.str;
+          default = "origin";
+          description = "Git remote name to push to";
+        };
+
+        mainBranch = mkOption {
+          type = types.str;
+          default = "main";
+          description = "Main branch name (for auto-merge)";
+        };
+
+        branchPrefix = mkOption {
+          type = types.str;
+          default = "flake-update-";
+          description = "Prefix for update branches";
+        };
+
+        flakeInputs = mkOption {
+          type = types.listOf types.str;
+          default = [ ];
+          example = [
+            "chick-group"
+            "chapeau-rouge"
+          ];
+          description = "List of specific flake inputs to update (empty = all)";
+        };
+
+        autoMerge = mkOption {
+          type = types.bool;
+          default = false;
+          description = "If true, automatically merge to main branch on successful build";
+        };
+
+        inboxOrg = mkOption {
+          type = types.str;
+          default = "/home/${config.user}/desktop/org/inbox.org";
+          example = "/home/user/org/inbox.org";
+          description = "Path to org-mode inbox file for TODO entries on failure";
+        };
+
+        buildSystems = mkOption {
+          type = types.listOf types.str;
+          default = [ ];
+          example = [
+            "aomi"
+            "sakhalin"
+          ];
+          description = "List of NixOS systems to build for verification";
+        };
+
+        schedule = mkOption {
+          type = types.str;
+          default = "weekly";
+          example = "Mon *-*-* 02:00:00";
+          description = "Systemd timer schedule (OnCalendar format or 'weekly'/'daily')";
+        };
+
+        ntfyTopic = mkOption {
+          type = types.str;
+          default = "nix-updates";
+          description = "ntfy topic for notifications";
+        };
+
+        ntfyServer = mkOption {
+          type = types.str;
+          default = "https://ntfy.sh";
+          example = "http://ntfy.sbr.pm";
+          description = "ntfy server URL";
+        };
+
+        ntfyTokenFile = mkOption {
+          type = types.nullOr types.path;
+          default = null;
+          description = "Path to file containing ntfy authentication token (optional)";
+        };
+
+        dryRun = mkOption {
+          type = types.bool;
+          default = false;
+          description = "If true, don't push to remote (testing mode)";
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "root";
+          description = "User to run the update as";
+        };
+
+        randomizedDelaySec = mkOption {
+          type = types.int;
+          default = 3600;
+          description = "Random delay in seconds before starting (0-value)";
+        };
+      };
     };
 
-    flakePath = mkOption {
-      type = types.str;
-      default = cfg.repoPath;
-      example = "/home/user/nixos-config";
-      description = "Path to the flake (usually same as repoPath)";
-    };
+  mkUpdateScript =
+    name: instanceCfg:
+    pkgs.writeShellScript "nix-flake-update-${name}" ''
+      export REPO_PATH="${instanceCfg.repoPath}"
+      export FLAKE_PATH="${instanceCfg.flakePath}"
+      export GIT_REMOTE="${instanceCfg.gitRemote}"
+      export MAIN_BRANCH="${instanceCfg.mainBranch}"
+      export BRANCH_PREFIX="${instanceCfg.branchPrefix}"
+      export NTFY_TOPIC="${instanceCfg.ntfyTopic}"
+      export NTFY_SERVER="${instanceCfg.ntfyServer}"
+      export BUILD_SYSTEMS="${toString instanceCfg.buildSystems}"
+      export DRY_RUN="${toString instanceCfg.dryRun}"
+      export FLAKE_INPUTS="${toString instanceCfg.flakeInputs}"
+      export AUTO_MERGE="${toString instanceCfg.autoMerge}"
+      export INBOX_ORG="${instanceCfg.inboxOrg}"
+      ${optionalString (
+        instanceCfg.ntfyTokenFile != null
+      ) ''export NTFY_TOKEN_FILE="${instanceCfg.ntfyTokenFile}"''}
 
-    gitRemote = mkOption {
-      type = types.str;
-      default = "origin";
-      description = "Git remote name to push to";
-    };
+      # Execute the packaged update script (already has tools in PATH)
+      exec ${pkgs.nix-flake-update}/bin/nix-flake-update
+    '';
 
-    branchPrefix = mkOption {
-      type = types.str;
-      default = "flake-update-";
-      description = "Prefix for update branches";
-    };
-
-    buildSystems = mkOption {
-      type = types.listOf types.str;
-      default = [ ];
-      example = [
-        "aomi"
-        "sakhalin"
-      ];
-      description = "List of NixOS systems to build for verification";
-    };
-
-    schedule = mkOption {
-      type = types.str;
-      default = "weekly";
-      example = "Mon *-*-* 02:00:00";
-      description = "Systemd timer schedule (OnCalendar format or 'weekly'/'daily')";
-    };
-
-    ntfyTopic = mkOption {
-      type = types.str;
-      default = "nix-updates";
-      description = "ntfy topic for notifications";
-    };
-
-    ntfyServer = mkOption {
-      type = types.str;
-      default = "https://ntfy.sh";
-      example = "http://ntfy.sbr.pm";
-      description = "ntfy server URL";
-    };
-
-    ntfyTokenFile = mkOption {
-      type = types.nullOr types.path;
-      default = null;
-      description = "Path to file containing ntfy authentication token (optional)";
-    };
-
-    dryRun = mkOption {
-      type = types.bool;
-      default = false;
-      description = "If true, don't push to remote (testing mode)";
-    };
-
-    user = mkOption {
-      type = types.str;
-      default = "root";
-      description = "User to run the update as";
-    };
-
-    randomizedDelaySec = mkOption {
-      type = types.int;
-      default = 3600;
-      description = "Random delay in seconds before starting (0-value)";
-    };
-  };
-
-  config = mkIf cfg.enable {
-    systemd.services.nix-flake-updater = {
-      description = "Automated Nix flake.lock updater";
+  mkService =
+    name: instanceCfg:
+    nameValuePair "nix-flake-updater-${name}" {
+      description = "Automated Nix flake.lock updater (${name})";
 
       serviceConfig = {
         Type = "oneshot";
-        User = cfg.user;
-        ExecStart = "${updateScript}";
+        User = instanceCfg.user;
+        ExecStart = "${mkUpdateScript name instanceCfg}";
         Environment = ''"GIT_SSH_COMMAND=ssh -o ControlMaster=no"'';
 
         # Security hardening
@@ -126,19 +165,21 @@ in
         ProtectSystem = "strict";
         ProtectHome = "read-only";
         ReadWritePaths = [
-          cfg.repoPath
+          instanceCfg.repoPath
           "/var/log/nix-flake-updater"
           # Worktree location (script creates worktrees in ~/tmp)
-          "/home/${cfg.user}/tmp"
+          "/home/${instanceCfg.user}/tmp"
           # Nix cache for flake fetcher
-          "/home/${cfg.user}/.cache/nix"
+          "/home/${instanceCfg.user}/.cache/nix"
+          # Org inbox for TODOs
+          (dirOf instanceCfg.inboxOrg)
         ];
         NoNewPrivileges = true;
 
         # Logging
         StandardOutput = "journal";
         StandardError = "journal";
-        SyslogIdentifier = "nix-flake-updater";
+        SyslogIdentifier = "nix-flake-updater-${name}";
       };
 
       # Don't fail if update fails (e.g., no changes, build failures)
@@ -147,20 +188,39 @@ in
       };
     };
 
-    systemd.timers.nix-flake-updater = {
-      description = "Timer for automated Nix flake.lock updates";
+  mkTimer =
+    name: instanceCfg:
+    nameValuePair "nix-flake-updater-${name}" {
+      description = "Timer for automated Nix flake.lock updates (${name})";
       wantedBy = [ "timers.target" ];
 
       timerConfig = {
-        OnCalendar = cfg.schedule;
-        RandomizedDelaySec = cfg.randomizedDelaySec;
+        OnCalendar = instanceCfg.schedule;
+        RandomizedDelaySec = instanceCfg.randomizedDelaySec;
         Persistent = true;
       };
     };
 
-    # Ensure log directory exists
+in
+{
+  options.services.nix-flake-updater = mkOption {
+    type = types.attrsOf (types.submodule instanceOpts);
+    default = { };
+    description = "Automated Nix flake.lock updater instances";
+  };
+
+  config = mkIf (cfg != { }) {
+    systemd.services = listToAttrs (
+      mapAttrsToList (name: instanceCfg: mkService name instanceCfg) (filterAttrs (_: v: v.enable) cfg)
+    );
+
+    systemd.timers = listToAttrs (
+      mapAttrsToList (name: instanceCfg: mkTimer name instanceCfg) (filterAttrs (_: v: v.enable) cfg)
+    );
+
+    # Ensure log directory exists (shared by all instances)
     systemd.tmpfiles.rules = [
-      "d /var/log/nix-flake-updater 0750 ${cfg.user} ${config.users.users.${cfg.user}.group} -"
+      "d /var/log/nix-flake-updater 0750 root root -"
     ];
   };
 }
systems/aomi/extra.nix
@@ -151,42 +151,88 @@
 
   # Automated flake.lock updates with build verification
   services.nix-flake-updater = {
-    enable = true;
-    repoPath = "/home/vincent/src/home";
+    # Weekly updates for all inputs
+    weekly = {
+      enable = true;
+      repoPath = "/home/vincent/src/home";
 
-    # Build systems across both architectures for verification
-    buildSystems = [
-      # x86_64-linux systems
-      "aomi" # Self (laptop/build server)
-      "kyushu" # Work laptop
-      "sakhalin" # Server
-      "kerkouane" # VPS server
+      # Build systems across both architectures for verification
+      buildSystems = [
+        # x86_64-linux systems
+        "aomi" # Self (laptop/build server)
+        "kyushu" # Work laptop
+        "sakhalin" # Server
+        "kerkouane" # VPS server
 
-      # aarch64-linux systems
-      "rhea" # Main media server
-      "aion" # XMPP/podcast server
-      "athena" # Raspberry Pi 4
-      "demeter" # Raspberry Pi 4
-      "aix" # Raspberry Pi 4
-    ];
+        # aarch64-linux systems
+        "rhea" # Main media server
+        "aion" # XMPP/podcast server
+        "athena" # Raspberry Pi 4
+        "demeter" # Raspberry Pi 4
+        "aix" # Raspberry Pi 4
+      ];
 
-    # Run weekly on Sunday at 2 AM
-    schedule = "Sun *-*-* 02:00:00";
+      # Run weekly on Sunday at 2 AM
+      schedule = "Sun *-*-* 02:00:00";
 
-    # Notifications via ntfy
-    ntfyServer = "https://ntfy.sbr.pm";
-    ntfyTopic = "nix-updates";
-    ntfyTokenFile = config.age.secrets."ntfy-token".path;
+      # Notifications via ntfy
+      ntfyServer = "https://ntfy.sbr.pm";
+      ntfyTopic = "nix-updates";
+      ntfyTokenFile = config.age.secrets."ntfy-token".path;
 
-    # Git settings
-    gitRemote = "origin";
-    branchPrefix = "flake-update-";
+      # Git settings
+      gitRemote = "origin";
+      branchPrefix = "flake-update-";
 
-    # Run as vincent (has git push access)
-    user = "vincent";
+      # Run as vincent (has git push access)
+      user = "vincent";
 
-    # Add randomized delay to avoid conflicts
-    randomizedDelaySec = 1800; # 0-30 min delay
+      # Add randomized delay to avoid conflicts
+      randomizedDelaySec = 1800; # 0-30 min delay
+    };
+
+    # Daily automated updates for chick-group and chapeau-rouge with auto-merge
+    daily = {
+      enable = true;
+      repoPath = "/home/vincent/src/home";
+
+      # Update only personal repos
+      flakeInputs = [
+        "chick-group"
+        "chapeau-rouge"
+      ];
+
+      # Auto-merge to main on successful build
+      autoMerge = true;
+
+      # Build fewer systems for faster daily updates
+      buildSystems = [
+        "aomi" # Self (x86_64-linux)
+        "kyushu" # Work laptop (x86_64-linux)
+      ];
+
+      # Run daily at 4 AM
+      schedule = "*-*-* 04:00:00";
+
+      # Notifications via ntfy (same topic as weekly)
+      ntfyServer = "https://ntfy.sbr.pm";
+      ntfyTopic = "nix-updates";
+      ntfyTokenFile = config.age.secrets."ntfy-token".path;
+
+      # Git settings
+      gitRemote = "origin";
+      mainBranch = "main";
+      branchPrefix = "auto-update-daily-";
+
+      # Org inbox for failure TODOs
+      inboxOrg = "/home/vincent/desktop/org/inbox.org";
+
+      # Run as vincent (has git push access)
+      user = "vincent";
+
+      # Smaller delay for daily updates
+      randomizedDelaySec = 600; # 0-10 min delay
+    };
   };
 
   services = {
tools/nix-flake-update/nix-flake-update.sh
@@ -8,12 +8,16 @@ set -euo pipefail
 REPO_PATH="${REPO_PATH:-/home/vincent/src/home}"
 FLAKE_PATH="${FLAKE_PATH:-$REPO_PATH}"
 GIT_REMOTE="${GIT_REMOTE:-origin}"
+MAIN_BRANCH="${MAIN_BRANCH:-main}"
 BRANCH_PREFIX="${BRANCH_PREFIX:-flake-update-}"
 NTFY_TOPIC="${NTFY_TOPIC:-nix-updates}"
 NTFY_SERVER="${NTFY_SERVER:-https://ntfy.sh}"
 NTFY_TOKEN_FILE="${NTFY_TOKEN_FILE:-}"
 BUILD_SYSTEMS="${BUILD_SYSTEMS:-}"
 DRY_RUN="${DRY_RUN:-false}"
+FLAKE_INPUTS="${FLAKE_INPUTS:-}"  # Space-separated list of inputs to update (empty = all)
+AUTO_MERGE="${AUTO_MERGE:-false}"  # If true, merge to main on success
+INBOX_ORG="${INBOX_ORG:-$HOME/desktop/org/inbox.org}"  # Path to org-mode inbox
 
 LOG_FILE="/var/log/nix-flake-updater/$(date +%Y%m%d-%H%M%S).log"
 mkdir -p "$(dirname "$LOG_FILE")"
@@ -52,6 +56,28 @@ notify() {
   fi
 }
 
+add_todo_to_inbox() {
+  local title="$1"
+  local details="$2"
+
+  if [ -f "$INBOX_ORG" ]; then
+    log "Adding TODO to $INBOX_ORG"
+    cat >> "$INBOX_ORG" <<EOF
+* TODO $title
+  SCHEDULED: <$(date '+%Y-%m-%d %a')>
+  :PROPERTIES:
+  :CREATED: [$(date '+%Y-%m-%d %a %H:%M')]
+  :END:
+
+$details
+
+Log file: $LOG_FILE
+EOF
+  else
+    log "WARNING: Inbox file not found: $INBOX_ORG"
+  fi
+}
+
 cleanup() {
   local exit_code=$?
 
@@ -65,8 +91,20 @@ cleanup() {
 
   if [ $exit_code -ne 0 ]; then
     log "ERROR: Update process failed with exit code $exit_code"
+    
+    # Add TODO to inbox on failure
+    local input_desc="all inputs"
+    if [ -n "$FLAKE_INPUTS" ]; then
+      input_desc="inputs: $FLAKE_INPUTS"
+    fi
+    
+    add_todo_to_inbox "Fix flake update failure" \
+      "Flake update failed for $input_desc.
+Build systems: $BUILD_SYSTEMS
+Auto-merge: $AUTO_MERGE"
+    
     notify "high" "❌ Flake Update Failed" \
-      "Build failed. See logs: $LOG_FILE" \
+      "Build failed for $input_desc. TODO added to inbox. See logs: $LOG_FILE" \
       "warning,flake"
   fi
 }
@@ -101,7 +139,16 @@ git checkout -b "$BRANCH_NAME"
 
 # Update flake.lock (work in worktree, flake is at root)
 log "Updating flake.lock"
-nix flake update 2>&1 | tee -a "$LOG_FILE"
+if [ -n "$FLAKE_INPUTS" ]; then
+  log "Updating specific inputs: $FLAKE_INPUTS"
+  for input in $FLAKE_INPUTS; do
+    log "Updating input: $input"
+    nix flake lock --update-input "$input" 2>&1 | tee -a "$LOG_FILE"
+  done
+else
+  log "Updating all inputs"
+  nix flake update 2>&1 | tee -a "$LOG_FILE"
+fi
 
 # Check if there are changes
 if ! git diff --quiet flake.lock; then
@@ -131,7 +178,12 @@ if ! git diff --quiet flake.lock; then
     git add flake.lock
 
     # Generate commit message with changed inputs
-    COMMIT_MSG="chore(flake): update flake.lock
+    input_desc="all inputs"
+    if [ -n "$FLAKE_INPUTS" ]; then
+      input_desc="$FLAKE_INPUTS"
+    fi
+    
+    COMMIT_MSG="chore(flake): update $input_desc
 
 $(nix flake metadata . --json 2>/dev/null | \
   jq -r '.locks.nodes | to_entries[] | select(.key != "root") | "- \(.key): \(.value.locked.rev // .value.locked.narHash // "updated")"' 2>/dev/null || echo "Updated flake inputs")
@@ -143,16 +195,52 @@ Built systems: $BUILD_SYSTEMS
     git commit -m "$COMMIT_MSG"
 
     if [ "$DRY_RUN" = "false" ]; then
-      # Push to remote
-      log "Pushing to $GIT_REMOTE/$BRANCH_NAME"
-      git push "$GIT_REMOTE" "$BRANCH_NAME"
+      if [ "$AUTO_MERGE" = "true" ]; then
+        # Auto-merge: rebase onto main and push directly
+        log "Auto-merge enabled: rebasing onto $GIT_REMOTE/$MAIN_BRANCH"
+        
+        # Fetch latest main
+        git fetch "$GIT_REMOTE" "$MAIN_BRANCH"
+        
+        # Rebase our commit onto main
+        if git rebase "$GIT_REMOTE/$MAIN_BRANCH"; then
+          log "Rebase successful, pushing to $GIT_REMOTE/$MAIN_BRANCH"
+          
+          # Push directly to main
+          git push "$GIT_REMOTE" "HEAD:$MAIN_BRANCH"
+          
+          # Notify success
+          notify "default" "✅ Flake Auto-Updated & Merged" \
+            "Updates for $input_desc merged to $MAIN_BRANCH. All builds passed: $BUILD_SYSTEMS" \
+            "white_check_mark,flake,merged"
+          
+          log "SUCCESS: Flake updated and merged to $MAIN_BRANCH"
+        else
+          log "ERROR: Rebase failed, main branch may have moved"
+          git rebase --abort || true
+          
+          add_todo_to_inbox "Flake update rebase conflict" \
+            "Auto-merge failed due to rebase conflict.
+Inputs: $input_desc
+Branch: $BRANCH_NAME (in worktree, needs manual rebase)"
+          
+          notify "high" "⚠️ Flake Update Rebase Failed" \
+            "Could not rebase $input_desc onto $MAIN_BRANCH. TODO added to inbox." \
+            "warning,flake,conflict"
+          exit 1
+        fi
+      else
+        # Branch mode: push to feature branch
+        log "Pushing to $GIT_REMOTE/$BRANCH_NAME"
+        git push "$GIT_REMOTE" "$BRANCH_NAME"
 
-      # Notify success
-      notify "default" "✅ Flake Updated Successfully" \
-        "Branch $BRANCH_NAME created and pushed. All builds passed: $BUILD_SYSTEMS" \
-        "white_check_mark,flake"
+        # Notify success
+        notify "default" "✅ Flake Updated Successfully" \
+          "Branch $BRANCH_NAME created and pushed. All builds passed: $BUILD_SYSTEMS" \
+          "white_check_mark,flake"
 
-      log "SUCCESS: Flake updated and pushed to $BRANCH_NAME"
+        log "SUCCESS: Flake updated and pushed to $BRANCH_NAME"
+      fi
     else
       log "DRY RUN: Would push to $GIT_REMOTE/$BRANCH_NAME"
       notify "low" "🧪 Flake Update (Dry Run)" \
@@ -162,8 +250,19 @@ Built systems: $BUILD_SYSTEMS
 
   else
     log "Build failed, not committing changes"
+    
+    input_desc="all inputs"
+    if [ -n "$FLAKE_INPUTS" ]; then
+      input_desc="$FLAKE_INPUTS"
+    fi
+    
+    add_todo_to_inbox "Flake update build failure" \
+      "Build failed after updating $input_desc.
+Build systems tested: $BUILD_SYSTEMS
+Auto-merge: $AUTO_MERGE"
+    
     notify "high" "❌ Flake Update Build Failed" \
-      "Builds failed for updated flake.lock. Check logs: $LOG_FILE" \
+      "Builds failed for updated $input_desc. TODO added to inbox. Check logs: $LOG_FILE" \
       "x,flake,warning"
 
     # Clean up failed branch in main repo