Commit 3dd56e2

HPCesia <me@hpcesia.com>
2026-09-03 16:10:16
ci: update script based package auto updater
Assisted-by: opencode:deepseek-v4-flash
1 parent 9fa2c5d
.forgejo/workflows/auto-update.yml
@@ -0,0 +1,35 @@
+name: "Auto update packages"
+on:
+  workflow_dispatch:
+  schedule:
+    - cron: "13 20 * * *"
+# See https://forgejo.org/docs/latest/user/api/authorized-integrations/
+enable-openid-connect: true
+env:
+  AUTHORIZED_INTEGRATION_AUDIENCE: "u:302663:cf6d5efd-a70c-4b33-a5af-d9b6eea3b0bd"
+jobs:
+  auto-update:
+    runs-on: nixos-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v6
+        with:
+          # Do not persist the (read-only) automatic token; all git operations
+          # authenticate with the Authorized Integration JWT instead.
+          persist-credentials: false
+      - name: Fetch JWT for authorized integration
+        id: jwt
+        run: |
+          jwt=$(
+            curl --fail \
+              -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
+              "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUTHORIZED_INTEGRATION_AUDIENCE" \
+            | jq -r .value
+          )
+          echo "::add-mask::$jwt"
+          echo "jwt=$jwt" >> "$FORGEJO_OUTPUT"
+      - name: Update packages and open PRs
+        env:
+          FORGEJO_TOKEN: ${{ steps.jwt.outputs.jwt }}
+        run: |
+          nix-shell shell.nix --run './scripts/update-ci.sh'
scripts/update-ci.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# Update all packages with passthru.updateScript and open one PR per package.
+# Intended to run inside `nix-shell shell.nix` in CI (see
+# .forgejo/workflows/auto-update.yml). Expects FORGEJO_TOKEN to be set.
+set -euo pipefail
+
+FORGEJO_TOKEN="${FORGEJO_TOKEN:?FORGEJO_TOKEN not set}"
+
+# Some packages (e.g. harmonoid, navibeat) are unfree; nix-instantiate
+# refuses them without this.
+export NIXPKGS_ALLOW_UNFREE=1
+
+MAIN_BRANCH=$(git symbolic-ref --short HEAD)
+REPO_REMOTE_URL=$(git remote get-url origin)
+
+# In CI the token is a short-lived JWT minted for a Forgejo "Authorized
+# Integration" (see .forgejo/workflows/auto-update.yml). JWTs authenticate over
+# HTTP via an `Authorization: Bearer` header and cannot be embedded in the
+# remote URL, unlike a classic access token (local runs).
+if [[ "$FORGEJO_TOKEN" =~ ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]]; then
+	IS_JWT=1
+else
+	IS_JWT=0
+fi
+
+fj auth add-token "$FORGEJO_TOKEN"
+
+case "$REPO_REMOTE_URL" in
+https://*)
+	if [[ "$IS_JWT" -eq 1 ]]; then
+		git config --local http.extraHeader "Authorization: Bearer ${FORGEJO_TOKEN}"
+	else
+		git remote set-url origin "https://oauth2:${FORGEJO_TOKEN}@${REPO_REMOTE_URL#https://}"
+	fi
+	;;
+esac
+
+git config user.name "nur-update-bot"
+git config user.email "nur-update-bot@users.noreply.git.net.trin.one"
+
+FAILED=""
+PENDING=""
+
+for PKG in $(./scripts/update-package --list | jq -r '.[]'); do
+	echo ""
+	echo "=== $PKG ==="
+
+	# Reset to a clean main state.
+	git checkout -f "$MAIN_BRANCH"
+	git checkout -- . 2>/dev/null || true
+	git clean -fd -- pkgs/ 2>/dev/null || true
+
+	# Skip if a PR for this package is already open (branch exists on origin).
+	if git ls-remote --exit-code origin "refs/heads/update/$PKG" >/dev/null 2>&1; then
+		echo ">>> $PKG: PR already open, skipping"
+		PENDING="$PENDING $PKG"
+		continue
+	fi
+
+	OLD_VERSION=$(nix-instantiate --eval --strict -A "$PKG.version" . 2>/dev/null | tr -d '"')
+
+	if ! ./scripts/update-package "$PKG"; then
+		echo ">>> $PKG: update script failed" >&2
+		FAILED="$FAILED $PKG"
+		continue
+	fi
+
+	if [ -z "$(git status --porcelain -- pkgs/)" ]; then
+		echo ">>> $PKG: no changes"
+		continue
+	fi
+
+	if ! nix-instantiate --show-trace -A "$PKG" . >/dev/null 2>&1; then
+		echo ">>> $PKG: evaluation failed after update" >&2
+		FAILED="$FAILED $PKG"
+		continue
+	fi
+
+	NEW_VERSION=$(nix-instantiate --eval --strict -A "$PKG.version" . 2>/dev/null | tr -d '"')
+
+	python3 ./scripts/gen-readme.py
+
+	git switch -c "update/$PKG"
+	git add -A
+	git commit -m "$PKG: $OLD_VERSION -> $NEW_VERSION"
+	git push --set-upstream origin "update/$PKG"
+	fj pr create "$PKG: $OLD_VERSION -> $NEW_VERSION" \
+		--body "Automated update by CI.
+
+$PKG: \`$OLD_VERSION\` -> \`$NEW_VERSION\`"
+done
+
+if [ -n "$PENDING" ]; then
+	echo ""
+	echo "Pending (PR already open):$PENDING"
+fi
+if [ -n "$FAILED" ]; then
+	echo ""
+	echo "Finished with failures:$FAILED" >&2
+	exit 1
+fi
+echo ""
+echo "All packages up to date."
scripts/update-package
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Run passthru.updateScript-based package updates (see scripts/update.nix).
+#
+# Usage:
+#   ./scripts/update-package <attr-path> [<attr-path>...]
+#   ./scripts/update-package --all
+#   ./scripts/update-package --list
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+case "${1:-}" in
+--all)
+	exec nix-shell scripts/update.nix --argstr packages ""
+	;;
+--list)
+	exec nix-instantiate --eval --json --strict \
+		-E 'map (p: p.attrPath) (import ./scripts/update-packages.nix {})'
+	;;
+"")
+	echo "Usage: $0 <attr-path>... | --all | --list" >&2
+	exit 1
+	;;
+*)
+	exec nix-shell scripts/update.nix --argstr packages "$*"
+	;;
+esac
\ No newline at end of file
scripts/update-packages.nix
@@ -0,0 +1,121 @@
+/*
+Selection of packages with `passthru.updateScript`, shared by the
+runner (scripts/update.nix) and scripts/update-package --list.
+
+Usage:
+
+    nix-instantiate --eval --json --strict \
+      -E 'map (p: p.attrPath) (import ./scripts/update-packages.nix {})'
+*/
+{
+  pkgs ? import <nixpkgs> {},
+  # Space-separated list of package attribute paths to update.
+  # Empty string means "update everything".
+  packages ? "",
+}: let
+  inherit (pkgs) lib;
+  nurPkgs = import ../default.nix {inherit pkgs;};
+
+  updateScriptOf = pkg: let
+    script =
+      (
+        if pkg.passthru or null != null
+        then pkg.passthru
+        else {}
+      ).updateScript or null;
+  in
+    if script == null
+    then null
+    else if lib.isDerivation script
+    then [(toString script)]
+    else if lib.isAttrs script
+    then
+      if script ? command
+      then map toString (lib.toList script.command)
+      else null
+    else map toString (lib.toList script);
+
+  reservedNames = [
+    "lib"
+    "overlays"
+    "nixosModules"
+    "homeModules"
+    "darwinModules"
+    "flakeModules"
+  ];
+
+  collect = prefix: attrs: let
+    names = builtins.tryEval (builtins.attrNames attrs);
+  in
+    if !names.success
+    then []
+    else
+      builtins.concatLists (
+        map (
+          name: let
+            result = builtins.tryEval attrs.${name};
+          in
+            if !result.success
+            then []
+            else if lib.isDerivation result.value
+            then let
+              script = updateScriptOf result.value;
+              meta = result.value.meta or null;
+              position =
+                if meta != null
+                then meta.position or null
+                else null;
+            in
+              lib.optional (script != null && !(meta.nurRenamed or false) && !(meta.nurDeprecated or false)) {
+                inherit name;
+                attrPath = lib.concatStringsSep "." (prefix ++ [name]);
+                inherit script;
+                pname = result.value.pname or (lib.getName result.value);
+                pkgName = result.value.name;
+                oldVersion = result.value.version or "";
+                inherit position;
+              }
+            else if lib.isAttrs result.value
+            then collect (prefix ++ [name]) result.value
+            else []
+        )
+        names.value
+      );
+
+  allPackages = collect [] (lib.filterAttrs (name: _: !builtins.elem name reservedNames) nurPkgs);
+
+  deduplicated = let
+    isClean = p: !(p.meta.nurRenamed or false) && !(p.meta.nurDeprecated or false);
+    grouped =
+      lib.foldl (
+        acc: cur: let
+          key = builtins.hashString "sha256" (
+            lib.concatStringsSep "\n" (cur.script ++ [(toString cur.position)])
+          );
+          prev = acc.${key} or null;
+          keep =
+            prev
+            == null
+            || (isClean cur && !isClean prev)
+            || (isClean cur == isClean prev && lib.hasInfix "." cur.attrPath && !lib.hasInfix "." prev.attrPath);
+        in
+          acc
+          // {
+            ${key} =
+              if keep
+              then cur
+              else prev;
+          }
+      ) {}
+      allPackages;
+  in
+    map (k: grouped.${k}) (builtins.attrNames grouped);
+
+  matchesSelection = p: sel: p.attrPath == sel || lib.hasSuffix ".${sel}" p.attrPath;
+
+  selected =
+    if packages != ""
+    then lib.filter (p: lib.any (sel: matchesSelection p sel) (lib.splitString " " packages)) deduplicated
+    else deduplicated;
+in
+  selected
scripts/update.nix
@@ -0,0 +1,136 @@
+/*
+Runner for packages declaring `passthru.updateScript`, modeled after
+nixpkgs' maintainers/scripts/update.nix.
+
+Usage (also see scripts/update-package):
+
+    nix-shell scripts/update.nix --argstr packages "kelivo stinkpot"
+    nix-shell scripts/update.nix --argstr packages ""
+*/
+{
+  pkgs ? import <nixpkgs> {},
+  # Space-separated list of package attribute paths to update.
+  # Empty string means "update everything".
+  packages ? "",
+}: let
+  inherit (pkgs) lib;
+  selected = import ./update-packages.nix {inherit pkgs packages;};
+
+  invalidSelections = builtins.filter (sel: !lib.any (p: p.attrPath == sel || lib.hasSuffix ".${sel}" p.attrPath) selected) (
+    lib.filter (s: s != "") (lib.splitString " " packages)
+  );
+
+  packagesJson = pkgs.writeText "nur-update-packages.json" (
+    builtins.toJSON (
+      map (p: {
+        inherit (p) attrPath oldVersion;
+        inherit (p) pname;
+        name = p.pkgName;
+        updateScript = p.script;
+      })
+      selected
+    )
+  );
+
+  helpText = ''
+    No packages with passthru.updateScript were selected.
+  '';
+in
+  pkgs.stdenvNoCC.mkDerivation {
+    name = "nur-update-script";
+    nativeBuildInputs = [
+      pkgs.git
+      pkgs.cacert
+      pkgs.jq
+    ];
+    buildCommand = ''
+      echo ""
+      echo "Not possible to run update scripts using nix-build."
+      echo ""
+      echo "${lib.replaceStrings ["\n"] [" "] helpText}"
+      exit 1
+    '';
+    shellHook = ''
+      unset shellHook
+
+      ${lib.optionalString (invalidSelections != []) ''
+        echo "WARNING: no updateScript found for: ${lib.concatStringsSep " " invalidSelections}" >&2
+      ''}
+
+      ${lib.optionalString (selected == []) ''
+        echo "${lib.replaceStrings ["\n"] [" "] helpText}" >&2
+        exit 1
+      ''}
+
+      FAILED=""
+      LOGDIR=$(mktemp -d)
+      trap 'rm -rf "$LOGDIR"' EXIT
+      cd "${toString ./.}/.."
+
+      MAX_JOBS="''${UPDATE_PACKAGE_JOBS:-$(nproc)}"
+      declare -A JOB_ATTR=() JOB_LOG=()
+      RUNNING=0
+
+      launch() {
+        local PKG="$1" ATTR_PATH OLD_VERSION LOG
+        ATTR_PATH=$(jq -r .attrPath <<<"$PKG")
+        OLD_VERSION=$(jq -r .oldVersion <<<"$PKG")
+        LOG=$(mktemp "$LOGDIR/log.XXXXXX")
+        echo ">>> $ATTR_PATH: updating ($OLD_VERSION)"
+        (
+          if env \
+              UPDATE_NIX_ATTR_PATH="$ATTR_PATH" \
+              UPDATE_NIX_PNAME="$(jq -r .pname <<<"$PKG")" \
+              UPDATE_NIX_NAME="$(jq -r .name <<<"$PKG")" \
+              UPDATE_NIX_OLD_VERSION="$OLD_VERSION" \
+              bash -c "$(jq -r '.updateScript | map(@sh) | join(" ")' <<<"$PKG")"; then
+            echo ">>> $ATTR_PATH: done"
+            exit 0
+          else
+            echo ">>> $ATTR_PATH: FAILED" >&2
+            exit 1
+          fi
+        ) >"$LOG" 2>&1 &
+        JOB_ATTR[$!]="$ATTR_PATH"
+        JOB_LOG[$!]="$LOG"
+        RUNNING=$((RUNNING + 1))
+      }
+
+      reap_one() {
+        local PID STATUS ATTR_PATH LOG
+        PID=""
+        STATUS=0
+        # Requires bash >= 5.1 for `wait -n -p`
+        wait -n -p PID || STATUS=$?
+        ATTR_PATH="''${JOB_ATTR[$PID]}"
+        LOG="''${JOB_LOG[$PID]}"
+        echo ""
+        cat "$LOG"
+        rm -f "$LOG"
+        if [ "$STATUS" -ne 0 ]; then
+          FAILED="$FAILED $ATTR_PATH"
+        fi
+        unset "JOB_ATTR[$PID]" "JOB_LOG[$PID]"
+        RUNNING=$((RUNNING - 1))
+      }
+
+      while IFS= read -r PKG; do
+        while [ "$RUNNING" -ge "$MAX_JOBS" ]; do
+          reap_one
+        done
+        launch "$PKG"
+      done < <(jq -c '.[]' ${packagesJson})
+
+      while [ "$RUNNING" -gt 0 ]; do
+        reap_one
+      done
+
+      echo ""
+      if [ -n "$FAILED" ]; then
+        echo "Finished with failures:$FAILED" >&2
+        exit 1
+      fi
+      echo "All update scripts finished successfully."
+      exit 0
+    '';
+  }
justfile
@@ -2,3 +2,8 @@
 # Generate latest packages list and write to README.md
 gen-readme:
     python3 ./scripts/gen-readme.py
+
+# Run passthru.updateScript-based package updates
+# Usage: just update-pkg [--all | <attr-path>...]
+update-pkg *args:
+    ./scripts/update-package {{args}}
shell.nix
@@ -1,6 +1,9 @@
 {pkgs ? import <nixpkgs> {}}:
 pkgs.mkShellNoCC {
   packages = with pkgs; [
+    forgejo-cli
+    jq
     nurl
+    python3
   ];
 }