Nix packages for third-party projects that don't ship their own flakes
0

Configure Feed

Select the types of activity you want to include in your feed.

Add weekly GitHub Action to bump package versions

Introduce scripts/update-packages.sh driven by packages/*/upstream.json,
plus a Monday cron (and workflow_dispatch) that refreshes hashes/lockfiles,
verifies nix builds, and opens a PR when anything changed.

+450 -5
+81
.github/workflows/update-packages.yml
··· 1 + name: Update packages 2 + 3 + on: 4 + schedule: 5 + # Mondays 06:17 UTC — off-peak, slightly jittered 6 + - cron: "17 6 * * 1" 7 + workflow_dispatch: 8 + inputs: 9 + packages: 10 + description: "Space-separated package names (empty = all)" 11 + required: false 12 + default: "" 13 + 14 + permissions: 15 + contents: write 16 + pull-requests: write 17 + 18 + concurrency: 19 + group: update-packages 20 + cancel-in-progress: false 21 + 22 + jobs: 23 + update: 24 + name: Bump package versions 25 + runs-on: ubuntu-latest 26 + timeout-minutes: 60 27 + steps: 28 + - name: Checkout 29 + uses: actions/checkout@v4 30 + 31 + - name: Install Nix 32 + uses: DeterminateSystems/nix-installer-action@v16 33 + 34 + - name: Magic Nix Cache 35 + uses: DeterminateSystems/magic-nix-cache-action@v9 36 + 37 + - name: Setup Node.js 38 + uses: actions/setup-node@v4 39 + with: 40 + node-version: "22" 41 + 42 + - name: Update packages from upstream 43 + env: 44 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 + run: | 46 + set -euo pipefail 47 + chmod +x scripts/update-packages.sh 48 + if [ -n "${{ inputs.packages }}" ]; then 49 + # workflow_dispatch with explicit package list 50 + # shellcheck disable=SC2086 51 + ./scripts/update-packages.sh ${{ inputs.packages }} 52 + else 53 + ./scripts/update-packages.sh 54 + fi 55 + 56 + - name: Create pull request 57 + uses: peter-evans/create-pull-request@v7 58 + with: 59 + token: ${{ secrets.GITHUB_TOKEN }} 60 + commit-message: | 61 + chore(packages): weekly upstream version bumps 62 + 63 + Automated update from scripts/update-packages.sh 64 + title: "chore(packages): weekly upstream version bumps" 65 + body: | 66 + ## Weekly package update 67 + 68 + This PR was opened by the [Update packages](../actions/workflows/update-packages.yml) workflow. 69 + 70 + It re-checks each `packages/*/upstream.json` source (GitHub tags, etc.), 71 + refreshes source hashes / lockfiles / `npmDepsHash`, and verifies 72 + `nix build` for each bumped package. 73 + 74 + ### Review checklist 75 + - [ ] Diff looks intentional (version + hashes + lockfile only) 76 + - [ ] `nix build .#<pkg>` works locally if you want a second check 77 + - [ ] Merge when ready — no human edits required unless a package needs a packaging fix 78 + branch: chore/update-packages 79 + delete-branch: true 80 + add-paths: | 81 + packages/**
+51 -5
README.md
··· 11 11 vit/ # one directory per package (callPackage style) 12 12 default.nix 13 13 package-lock.json # only when we need a fixed/regenerated lock 14 + upstream.json # how CI discovers and bumps this package 15 + scripts/ 16 + update-packages.sh # version bump + hash/lock refresh 17 + .github/workflows/ 18 + update-packages.yml # weekly cron + manual dispatch 14 19 ``` 15 20 16 21 Add a new package by creating `packages/<name>/default.nix` and wiring it in 17 - `flake.nix` via `pkgs.callPackage ./packages/<name> { }`. 22 + `flake.nix` via `pkgs.callPackage ./packages/<name> { }`. For automated 23 + updates, also add `packages/<name>/upstream.json` (see below). 18 24 19 25 ## Packages 20 26 ··· 35 41 nix profile install .#vit 36 42 ``` 37 43 38 - ## Updating `vit` 44 + ## Updating packages 39 45 40 - 1. Bump `version` / `rev` in `packages/vit/default.nix`. 46 + ### Automated (recommended) 47 + 48 + A GitHub Action runs **every Monday** (and on manual dispatch) to: 49 + 50 + 1. Read each `packages/*/upstream.json` 51 + 2. Compare the packaged version to the latest upstream tag 52 + 3. Refresh source hashes, regenerate lockfiles when needed, recompute `npmDepsHash` 53 + 4. Verify `nix build .#<pkg>` 54 + 5. Open a PR on branch `chore/update-packages` if anything changed 55 + 56 + ```bash 57 + # local dry-run: exit 1 if any package is behind upstream 58 + ./scripts/update-packages.sh --check 59 + 60 + # apply updates locally (requires nix, curl, npm) 61 + ./scripts/update-packages.sh # all packages 62 + ./scripts/update-packages.sh vit # one package 63 + ``` 64 + 65 + ### `upstream.json` 66 + 67 + Currently supported type: 68 + 69 + ```json 70 + { 71 + "type": "npm-github", 72 + "github": "owner/repo", 73 + "tag_prefix": "v" 74 + } 75 + ``` 76 + 77 + `npm-github` packages are expected to use `buildNpmPackage` + `fetchFromGitHub` 78 + with a `version` field and optional vendored `package-lock.json`. 79 + 80 + ### Manual steps (vit) 81 + 82 + If you prefer to bump by hand: 83 + 84 + 1. Bump `version` in `packages/vit/default.nix`. 41 85 2. Prefetch the new source hash: 42 86 ```bash 43 87 nix-prefetch-url --unpack "https://github.com/solpbc/vit/archive/refs/tags/vX.Y.Z.tar.gz" ··· 52 96 npm install --package-lock-only --ignore-scripts 53 97 cp package-lock.json /path/to/this/repo/packages/vit/package-lock.json 54 98 ``` 55 - 4. Set `npmDepsHash = lib.fakeHash;`, run `nix build .#vit`, paste the hash 56 - nix prints as `got:`, rebuild. 99 + 4. Set `npmDepsHash` to the all-zero fake hash, run `nix build .#vit`, paste the 100 + hash nix prints as `got:`, rebuild. 101 + 102 + Or just run `./scripts/update-packages.sh vit`.
+6
packages/vit/upstream.json
··· 1 + { 2 + "type": "npm-github", 3 + "github": "solpbc/vit", 4 + "tag_prefix": "v", 5 + "description": "Regenerate package-lock.json with resolved URLs after each version bump" 6 + }
+312
scripts/update-packages.sh
··· 1 + #!/usr/bin/env bash 2 + # Update packages/* from their upstream sources. 3 + # Intended for local use and weekly CI (see .github/workflows/update-packages.yml). 4 + # 5 + # Usage: 6 + # scripts/update-packages.sh # update all packages that have upstream.json 7 + # scripts/update-packages.sh vit # update only vit 8 + # scripts/update-packages.sh --check # report outdated packages; exit 1 if any 9 + set -euo pipefail 10 + 11 + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 12 + cd "$ROOT" 13 + 14 + FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" 15 + CHECK_ONLY=0 16 + PACKAGES=() 17 + 18 + log() { printf '==> %s\n' "$*"; } 19 + warn() { printf 'warning: %s\n' "$*" >&2; } 20 + die() { printf 'error: %s\n' "$*" >&2; exit 1; } 21 + 22 + need() { 23 + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" 24 + } 25 + 26 + for arg in "$@"; do 27 + case "$arg" in 28 + --check) CHECK_ONLY=1 ;; 29 + -h|--help) 30 + sed -n '2,10p' "$0" 31 + exit 0 32 + ;; 33 + -*) 34 + die "unknown flag: $arg" 35 + ;; 36 + *) 37 + PACKAGES+=("$arg") 38 + ;; 39 + esac 40 + done 41 + 42 + need curl 43 + need nix 44 + need nix-prefetch-url 45 + need python3 46 + 47 + if [[ ${#PACKAGES[@]} -eq 0 ]]; then 48 + while IFS= read -r d; do 49 + name="$(basename "$d")" 50 + [[ -f "$d/upstream.json" && -f "$d/default.nix" ]] || continue 51 + PACKAGES+=("$name") 52 + done < <(find "$ROOT/packages" -mindepth 1 -maxdepth 1 -type d | sort) 53 + fi 54 + 55 + [[ ${#PACKAGES[@]} -gt 0 ]] || die "no packages to update" 56 + 57 + read_field() { 58 + # read_field <file> <json-key> 59 + python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],""))' "$1" "$2" 60 + } 61 + 62 + current_version() { 63 + python3 -c ' 64 + import re, sys 65 + text = open(sys.argv[1]).read() 66 + m = re.search(r"version\s*=\s*\"([^\"]+)\"", text) 67 + if not m: 68 + sys.exit("version not found in " + sys.argv[1]) 69 + print(m.group(1)) 70 + ' "$1" 71 + } 72 + 73 + set_field_string() { 74 + # set_field_string <file> <attr> <value> — replaces first attr = "..." 75 + local file="$1" attr="$2" value="$3" 76 + python3 -c ' 77 + import re, sys 78 + path, attr, value = sys.argv[1], sys.argv[2], sys.argv[3] 79 + text = open(path).read() 80 + pat = re.compile(rf"({re.escape(attr)}\s*=\s*\")([^\"]*)(\")", re.M) 81 + new, n = pat.subn(rf"\g<1>{value}\g<3>", text, count=1) 82 + if n != 1: 83 + sys.exit(f"failed to set {attr} in {path} (matches={n})") 84 + open(path, "w").write(new) 85 + ' "$file" "$attr" "$value" 86 + } 87 + 88 + set_npm_deps_hash() { 89 + local file="$1" value="$2" 90 + python3 -c ' 91 + import re, sys 92 + path, value = sys.argv[1], sys.argv[2] 93 + text = open(path).read() 94 + pat = re.compile(r"(npmDepsHash\s*=\s*)([^;]+)(;)") 95 + new, n = pat.subn(rf"\g<1>\"{value}\"\g<3>", text, count=1) 96 + if n != 1: 97 + sys.exit(f"failed to set npmDepsHash in {path} (matches={n})") 98 + open(path, "w").write(new) 99 + ' "$file" "$value" 100 + } 101 + 102 + sri_from_url_unpack() { 103 + local url="$1" 104 + local base32 105 + base32="$(nix-prefetch-url --unpack "$url" 2>/dev/null)" 106 + nix hash convert --hash-algo sha256 --to sri "$base32" 107 + } 108 + 109 + latest_github_tag() { 110 + # latest_github_tag <owner/repo> <prefix> 111 + local repo="$1" prefix="$2" 112 + local url="https://api.github.com/repos/${repo}/tags?per_page=30" 113 + local args=(-fsSL) 114 + if [[ -n "${GITHUB_TOKEN:-}" ]]; then 115 + args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") 116 + fi 117 + curl "${args[@]}" "$url" | python3 -c ' 118 + import json, re, sys 119 + prefix = sys.argv[1] 120 + tags = json.load(sys.stdin) 121 + # Prefer semver-ish tags matching prefix 122 + pat = re.compile(r"^" + re.escape(prefix) + r"(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$") 123 + versions = [] 124 + for t in tags: 125 + name = t.get("name") or "" 126 + m = pat.match(name) 127 + if m: 128 + versions.append((m.group(1), name)) 129 + if not versions: 130 + sys.exit("no matching tags for prefix " + prefix) 131 + # sort by version components 132 + def key(v): 133 + ver = re.split(r"[+-]", v[0], maxsplit=1)[0] 134 + return [int(x) for x in ver.split(".")] 135 + versions.sort(key=key) 136 + print(versions[-1][0]) 137 + ' "$prefix" 138 + } 139 + 140 + version_gt() { 141 + # version_gt a b => true if a > b (semver-ish, numeric) 142 + python3 -c ' 143 + import re, sys 144 + def parts(v): 145 + core = re.split(r"[+-]", v, maxsplit=1)[0] 146 + return [int(x) for x in core.split(".")] 147 + a, b = sys.argv[1], sys.argv[2] 148 + sys.exit(0 if parts(a) > parts(b) else 1) 149 + ' "$1" "$2" 150 + } 151 + 152 + capture_npm_deps_hash() { 153 + # Build with fake hash; parse the "got:" line from the FOD mismatch. 154 + local attr="$1" 155 + local log 156 + log="$(mktemp)" 157 + set +e 158 + nix build "path:${ROOT}#${attr}" --print-build-logs >"$log" 2>&1 159 + local status=$? 160 + set -e 161 + local got 162 + got="$(python3 -c ' 163 + import re, sys 164 + text = open(sys.argv[1]).read() 165 + # Prefer the last "got:" line (npm-deps FOD). 166 + matches = re.findall(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", text) 167 + if not matches: 168 + sys.exit(1) 169 + print(matches[-1]) 170 + ' "$log" 2>/dev/null || true)" 171 + if [[ -z "$got" ]]; then 172 + warn "failed to capture npmDepsHash for ${attr}; build log:" 173 + tail -n 80 "$log" >&2 || true 174 + rm -f "$log" 175 + return 1 176 + fi 177 + rm -f "$log" 178 + printf '%s\n' "$got" 179 + return 0 180 + } 181 + 182 + verify_build() { 183 + local attr="$1" 184 + log "verifying nix build .#${attr}" 185 + nix build "path:${ROOT}#${attr}" --print-build-logs 186 + } 187 + 188 + regenerate_npm_lock() { 189 + # regenerate_npm_lock <owner/repo> <version> <dest-lock> 190 + local repo="$1" version="$2" dest="$3" 191 + need npm 192 + local tmp archive dir 193 + tmp="$(mktemp -d)" 194 + archive="${tmp}/src.tar.gz" 195 + dir="${tmp}/src" 196 + curl -fsSL "https://github.com/${repo}/archive/refs/tags/v${version}.tar.gz" -o "$archive" 197 + mkdir -p "$dir" 198 + tar -xzf "$archive" -C "$dir" --strip-components=1 199 + ( 200 + cd "$dir" 201 + rm -f package-lock.json bun.lock 202 + npm install --package-lock-only --ignore-scripts 203 + ) 204 + cp "$dir/package-lock.json" "$dest" 205 + rm -rf "$tmp" 206 + } 207 + 208 + update_npm_github() { 209 + local name="$1" 210 + local pkg_dir="$ROOT/packages/${name}" 211 + local default_nix="$pkg_dir/default.nix" 212 + local upstream="$pkg_dir/upstream.json" 213 + local repo tag_prefix 214 + repo="$(read_field "$upstream" github)" 215 + tag_prefix="$(read_field "$upstream" tag_prefix)" 216 + [[ -n "$repo" ]] || die "${name}: upstream.json missing github" 217 + tag_prefix="${tag_prefix:-v}" 218 + 219 + local cur latest 220 + cur="$(current_version "$default_nix")" 221 + log "${name}: current=${cur}" 222 + latest="$(latest_github_tag "$repo" "$tag_prefix")" 223 + log "${name}: latest upstream=${latest}" 224 + 225 + if ! version_gt "$latest" "$cur"; then 226 + log "${name}: already up to date" 227 + return 0 228 + fi 229 + 230 + if [[ "$CHECK_ONLY" -eq 1 ]]; then 231 + log "${name}: OUTDATED (${cur} -> ${latest})" 232 + return 10 233 + fi 234 + 235 + log "${name}: updating ${cur} -> ${latest}" 236 + set_field_string "$default_nix" version "$latest" 237 + 238 + local src_url src_hash 239 + src_url="https://github.com/${repo}/archive/refs/tags/${tag_prefix}${latest}.tar.gz" 240 + log "${name}: prefetching src ${src_url}" 241 + src_hash="$(sri_from_url_unpack "$src_url")" 242 + # The hash = "..." inside fetchFromGitHub — set the first hash = "..." after fetchFromGitHub 243 + python3 -c ' 244 + import re, sys 245 + path, value = sys.argv[1], sys.argv[2] 246 + text = open(path).read() 247 + # Replace hash inside fetchFromGitHub block only (first hash = "sha256-...") 248 + pat = re.compile(r"(fetchFromGitHub\s*\{[^}]*?hash\s*=\s*\")([^\"]+)(\")", re.S) 249 + new, n = pat.subn(rf"\g<1>{value}\g<3>", text, count=1) 250 + if n != 1: 251 + sys.exit(f"failed to set src hash (matches={n})") 252 + open(path, "w").write(new) 253 + ' "$default_nix" "$src_hash" 254 + log "${name}: src hash ${src_hash}" 255 + 256 + if [[ -f "$pkg_dir/package-lock.json" ]]; then 257 + log "${name}: regenerating package-lock.json" 258 + regenerate_npm_lock "$repo" "$latest" "$pkg_dir/package-lock.json" 259 + fi 260 + 261 + log "${name}: computing npmDepsHash" 262 + set_npm_deps_hash "$default_nix" "$FAKE_HASH" 263 + local npm_hash 264 + npm_hash="$(capture_npm_deps_hash "$name")" 265 + set_npm_deps_hash "$default_nix" "$npm_hash" 266 + log "${name}: npmDepsHash ${npm_hash}" 267 + 268 + verify_build "$name" 269 + log "${name}: updated successfully to ${latest}" 270 + } 271 + 272 + UPDATED=0 273 + OUTDATED=0 274 + 275 + for name in "${PACKAGES[@]}"; do 276 + pkg_dir="$ROOT/packages/${name}" 277 + [[ -d "$pkg_dir" ]] || die "unknown package: ${name}" 278 + [[ -f "$pkg_dir/upstream.json" ]] || die "${name}: missing packages/${name}/upstream.json" 279 + [[ -f "$pkg_dir/default.nix" ]] || die "${name}: missing packages/${name}/default.nix" 280 + 281 + type="$(read_field "$pkg_dir/upstream.json" type)" 282 + case "$type" in 283 + npm-github) 284 + status=0 285 + update_npm_github "$name" || status=$? 286 + if [[ $status -eq 10 ]]; then 287 + OUTDATED=$((OUTDATED + 1)) 288 + elif [[ $status -ne 0 ]]; then 289 + exit "$status" 290 + else 291 + # detect if files changed for this package 292 + if [[ "$CHECK_ONLY" -eq 0 ]] && ! git -C "$ROOT" diff --quiet -- "packages/${name}" 2>/dev/null; then 293 + UPDATED=$((UPDATED + 1)) 294 + fi 295 + fi 296 + ;; 297 + *) 298 + die "${name}: unsupported upstream type '${type}'" 299 + ;; 300 + esac 301 + done 302 + 303 + if [[ "$CHECK_ONLY" -eq 1 ]]; then 304 + if [[ "$OUTDATED" -gt 0 ]]; then 305 + log "${OUTDATED} package(s) outdated" 306 + exit 1 307 + fi 308 + log "all packages up to date" 309 + exit 0 310 + fi 311 + 312 + log "done (${UPDATED} package dir(s) with local changes)"