Watch Arch Linux package updates with a Forgejo Action

I wanted to know when specific Arch Linux packages got bumped -- for example when emacs moves from 30.2-2 to 30.2-3. I don't want to run pacman -Syu just to find out, and the Arch website has a JSON API that makes this easy to poll.

The API call for a single package looks like this:

curl -s 'https://archlinux.org/packages/search/json/?name=emacs&repo=Extra&arch=x86_64' \
    | jq -r '.results[0] | (.pkgver + "-" + (.pkgrel|tostring))'

This prints something like 30.2-2.

I wanted a generic solution, so the list of packages to watch lives in a packages.yaml file:

packages:
  - name: emacs
    repo: Extra
    arch: x86_64

Adding another package means appending three more lines.

The Forgejo Action runs once a day, queries each entry, and compares the result to a file in state/ that stores the last seen pkgver-pkgrel. If it differs, the Action sends a ntfy message (see previous post) and commits the new state file back to the repo. That way the git log of state/ becomes the bump history.

name: Check Arch package updates

on:
  schedule:
    - cron: '0 8 * * *'  # Daily at 8:00 AM UTC
  workflow_dispatch:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Install tools
        run: |
          command -v jq >/dev/null || { apt-get update -qq && apt-get install -y -qq jq; }
          if ! command -v yq >/dev/null; then
            case "$(uname -m)" in
              x86_64)         arch=amd64 ;;
              aarch64|arm64)  arch=arm64 ;;
              armv7l)         arch=arm ;;
              *) echo "unsupported arch: $(uname -m)"; exit 1 ;;
            esac
            curl -fsSL -o /usr/local/bin/yq \
              "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}"
            chmod +x /usr/local/bin/yq
          fi

      - name: Check packages
        env:
          NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
        run: |
          set -euo pipefail
          mkdir -p state

          yq -o=json '.packages' packages.yaml | jq -c '.[]' | while read -r pkg; do
            name=$(printf '%s' "$pkg" | jq -r '.name')
            repo=$(printf '%s' "$pkg" | jq -r '.repo')
            arch=$(printf '%s' "$pkg" | jq -r '.arch')

            url="https://archlinux.org/packages/search/json/?name=${name}&repo=${repo}&arch=${arch}"
            current=$(curl -sf "$url" \
              | jq -r '.results[0] | if . == null then empty else (.pkgver + "-" + (.pkgrel|tostring)) end')

            if [ -z "${current:-}" ]; then
              echo "::warning::no result for ${name} (${repo}/${arch})"
              continue
            fi

            state_file="state/${repo}_${arch}_${name}.txt"
            previous=$(cat "$state_file" 2>/dev/null || true)

            if [ "$previous" != "$current" ]; then
              printf '%s\n' "$current" > "$state_file"
              if [ -n "$previous" ]; then
                curl -fsS \
                     -H "Authorization: Bearer ${NTFY_TOKEN}" \
                     -H "Title: Arch package updated: ${name}" \
                     -H "Priority: default" \
                     -H "Tags: package" \
                     -d "${repo}/${name} (${arch}): ${previous} -> ${current}" \
                     https://ntfy.madflex.de/forgejo
              else
                echo "initial state for ${name}, not notifying"
              fi
            fi
          done

      - name: Commit and push state changes
        run: |
          git config user.name "Automated"
          git config user.email "actions@forgejo.local"
          git remote set-url origin https://oauth2:${{ secrets.GITHUB_TOKEN }}@forgejo.tail07efb.ts.net/${{ github.repository }}
          git add state/
          git diff --staged --quiet || git commit -m "Update package state - $(date +'%Y-%m-%d')"
          git push

One note about the state:

First-seen packages are recorded silently: when the state file does not exist yet, the current version is written but no ntfy is sent. That avoids a flood of notifications after adding a batch of new packages to packages.yaml.

Now a few days after I wrote the post, the Emacs package was updated and the notification looks like this:

img1

I am monitoring several other packages as well, but the problems with Emacs and Tree-sitter were the starting point.

tmux sessions

I started to use tmux sessions a lot in the last months. Some of my learnings are in this post.

Creating a new session without opening a new terminal

From inside an existing tmux session, there is no need to open another terminal. Just create a new session directly:

# from inside tmux (prefix + :)
:new-session -s work

Or use the shortcut:

# from the shell inside a tmux pane
tmux new-session -d -s work

The -d flag creates the session in the background (detached), so the current session stays in focus. Switch to it afterwards (see below).

Listing sessions

# from the shell
tmux ls

From inside tmux, Ctrl-b s opens an interactive session picker that also shows the windows inside each session.

Switching between sessions

Ctrl-b s

Opens the session list. Use arrow keys to pick one, Enter to switch.

Ctrl-b (

Switch to the previous session.

Ctrl-b )

Switch to the next session.

From the shell:

tmux switch-client -t work

Tree view of sessions and windows

Ctrl-b w

Opens an interactive tree view of every session, with each session expanded to show its windows (and panes). Navigate with the arrow keys, press Enter to jump straight to that window, or x to kill the highlighted item.

Renaming a session

Ctrl-b $

Prompts to rename the current session.

From the shell:

tmux rename-session -t old-name new-name

Showing the session name in the status bar

By default the tmux status bar does not make it obvious which session you are in. Adding #S to status-right in ~/.tmux.conf displays the current session name. Note that status-right is a single string and setting it replaces the previous value entirely -- so include everything you want shown:

set-option -g status-right '#[fg=cyan]#S #[fg=green]| #[fg=blue]#H #[fg=green]|#[fg=yellow]#(uptime | rev | cut -d":" -f1 | rev | sed s/,//g ) #[fg=green]| #[fg=blue]%Y-%m-%d %H:%M'

The #(...) form runs a shell command -- here a small uptime | rev | cut | rev | sed pipeline grabs just the three load averages from the end of uptime's output and strips the commas. Rendered, this looks like:

blogging | dunkelsturmtaucher | 0.58 1.01 1.24 | 2026-05-03 14:36

This puts the session name in front of the hostname, current load and date/time, separated by |. Reload the config with Ctrl-b :source-file ~/.tmux.conf to see the change immediately.

See also

Claude Code tmux window names -- an earlier post on auto-renaming tmux windows (rather than sessions) when running long-lived commands like Claude Code.

Mapping my car's history with InfluxDB and VersaTiles

My Homeassistant instance tracks my car's GPS position via device_tracker.car_location and stores it in InfluxDB. Homeassistant uses the Renault integration which talks to the same API I used for downloading my charge history. I wanted to see all historic positions on a map -- not just the current one in Homeassistant.

img1

The positions are only updated when a journey ends, so the data shows parking locations, not routes. Still a lot of parking positions are stored in InfluxDB.

Querying InfluxDB with Flux

The InfluxDB query uses pivot to combine the separate latitude and longitude field rows into a single row per timestamp:

from(bucket: "home_assistant")
  |> range(start: 2025-10-01T00:00:00Z)
  |> filter(fn: (r) => r._measurement == "device_tracker.car_location")
  |> filter(fn: (r) => r._field == "latitude" or r._field == "longitude")
  |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")

After a few months this returns tens of thousands of rows. Server-side deduplication in Python groups them by rounded coordinates into a much smaller set of unique locations with a visit count.

I tried doing the aggregation in Flux directly (group + count), but it timed out on my Raspberry Pi 4. The Python-side dedup is fast enough -- the bottleneck is InfluxDB scanning the rows (~2 seconds).

VersaTiles as map provider

Trying VersaTiles was on my list after I attended a talk about it last summer at FrosCon. It's an open-source stack serving OpenStreetMap vector tiles at tiles.versatiles.org. So no need to selfhost anything. But I could if I want to.

On the frontend it works with MapLibre GL JS. The @versatiles/style npm package provides ready-made map styles. Both are loaded as ES modules from jsdelivr:

<script type="module">
  import maplibregl from 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/+esm';
  import { colorful } from 'https://cdn.jsdelivr.net/npm/@versatiles/style@5/+esm';

  const style = colorful({ baseUrl: 'https://tiles.versatiles.org', language: 'de' });
  const map = new maplibregl.Map({ container: 'map', style });
</script>

Important: the baseUrl parameter is required, otherwise sprites and fonts resolve as relative URLs against my own server.

The backend

The whole backend is a single Flask file with one route for the API and has only two dependencies: flask and influxdb-client.

The positions are shown as red dots with a heatmap overlay -- the heatmap weight is based on how often the car was seen at each location. Hovering a dot shows the count.

Full Code (index.html + main.py) in a gist.