Nix packages for third-party projects that don't ship their own flakes
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
9set -euo pipefail
10
11ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12cd "$ROOT"
13
14FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
15CHECK_ONLY=0
16PACKAGES=()
17
18log() { printf '==> %s\n' "$*"; }
19warn() { printf 'warning: %s\n' "$*" >&2; }
20die() { printf 'error: %s\n' "$*" >&2; exit 1; }
21
22need() {
23 command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
24}
25
26for 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
40done
41
42need curl
43need nix
44need nix-prefetch-url
45need python3
46
47if [[ ${#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)
53fi
54
55[[ ${#PACKAGES[@]} -gt 0 ]] || die "no packages to update"
56
57read_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
62current_version() {
63 python3 -c '
64import re, sys
65text = open(sys.argv[1]).read()
66m = re.search(r"version\s*=\s*\"([^\"]+)\"", text)
67if not m:
68 sys.exit("version not found in " + sys.argv[1])
69print(m.group(1))
70' "$1"
71}
72
73set_field_string() {
74 # set_field_string <file> <attr> <value> — replaces first attr = "..."
75 local file="$1" attr="$2" value="$3"
76 python3 -c '
77import re, sys
78path, attr, value = sys.argv[1], sys.argv[2], sys.argv[3]
79text = open(path).read()
80pat = re.compile(rf"({re.escape(attr)}\s*=\s*\")([^\"]*)(\")", re.M)
81new, n = pat.subn(rf"\g<1>{value}\g<3>", text, count=1)
82if n != 1:
83 sys.exit(f"failed to set {attr} in {path} (matches={n})")
84open(path, "w").write(new)
85' "$file" "$attr" "$value"
86}
87
88set_npm_deps_hash() {
89 local file="$1" value="$2"
90 python3 -c '
91import re, sys
92path, value = sys.argv[1], sys.argv[2]
93text = open(path).read()
94pat = re.compile(r"(npmDepsHash\s*=\s*)([^;]+)(;)")
95new, n = pat.subn(rf"\g<1>\"{value}\"\g<3>", text, count=1)
96if n != 1:
97 sys.exit(f"failed to set npmDepsHash in {path} (matches={n})")
98open(path, "w").write(new)
99' "$file" "$value"
100}
101
102sri_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
109latest_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 '
118import json, re, sys
119prefix = sys.argv[1]
120tags = json.load(sys.stdin)
121# Prefer semver-ish tags matching prefix
122pat = re.compile(r"^" + re.escape(prefix) + r"(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$")
123versions = []
124for t in tags:
125 name = t.get("name") or ""
126 m = pat.match(name)
127 if m:
128 versions.append((m.group(1), name))
129if not versions:
130 sys.exit("no matching tags for prefix " + prefix)
131# sort by version components
132def key(v):
133 ver = re.split(r"[+-]", v[0], maxsplit=1)[0]
134 return [int(x) for x in ver.split(".")]
135versions.sort(key=key)
136print(versions[-1][0])
137' "$prefix"
138}
139
140version_gt() {
141 # version_gt a b => true if a > b (semver-ish, numeric)
142 python3 -c '
143import re, sys
144def parts(v):
145 core = re.split(r"[+-]", v, maxsplit=1)[0]
146 return [int(x) for x in core.split(".")]
147a, b = sys.argv[1], sys.argv[2]
148sys.exit(0 if parts(a) > parts(b) else 1)
149' "$1" "$2"
150}
151
152capture_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 '
163import re, sys
164text = open(sys.argv[1]).read()
165# Prefer the last "got:" line (npm-deps FOD).
166matches = re.findall(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", text)
167if not matches:
168 sys.exit(1)
169print(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
182verify_build() {
183 local attr="$1"
184 log "verifying nix build .#${attr}"
185 nix build "path:${ROOT}#${attr}" --print-build-logs
186}
187
188regenerate_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
208set_fetch_from_github_field() {
209 # set_fetch_from_github_field <file> <attr> <value>
210 # Replaces attr = "..." inside the first fetchFromGitHub { ... } block.
211 local file="$1" attr="$2" value="$3"
212 python3 -c '
213import re, sys
214path, attr, value = sys.argv[1], sys.argv[2], sys.argv[3]
215text = open(path).read()
216pat = re.compile(
217 rf"(fetchFromGitHub\s*\{{[^}}]*?{re.escape(attr)}\s*=\s*\")([^\"]+)(\")",
218 re.S,
219)
220new, n = pat.subn(rf"\g<1>{value}\g<3>", text, count=1)
221if n != 1:
222 sys.exit(f"failed to set fetchFromGitHub.{attr} in {path} (matches={n})")
223open(path, "w").write(new)
224' "$file" "$attr" "$value"
225}
226
227current_rev() {
228 python3 -c '
229import re, sys
230text = open(sys.argv[1]).read()
231m = re.search(r"fetchFromGitHub\s*\{[^}]*?rev\s*=\s*\"([^\"]+)\"", text, re.S)
232if not m:
233 sys.exit("rev not found in " + sys.argv[1])
234print(m.group(1))
235' "$1"
236}
237
238latest_github_commit() {
239 # latest_github_commit <owner/repo> <branch>
240 # prints: <sha> <YYYY-MM-DD>
241 local repo="$1" branch="$2"
242 local url="https://api.github.com/repos/${repo}/commits/${branch}"
243 local args=(-fsSL)
244 if [[ -n "${GITHUB_TOKEN:-}" ]]; then
245 args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
246 fi
247 curl "${args[@]}" "$url" | python3 -c '
248import json, sys
249c = json.load(sys.stdin)
250sha = c["sha"]
251date = c["commit"]["committer"]["date"][:10]
252print(sha, date)
253'
254}
255
256sri_from_url_file() {
257 local url="$1"
258 # nix-prefetch-url (no --unpack) for plain files (gems, etc.)
259 local base32
260 base32="$(nix-prefetch-url "$url" 2>/dev/null)"
261 nix hash convert --hash-algo sha256 --to sri "$base32"
262}
263
264refresh_vendored_gems() {
265 # refresh_vendored_gems <default.nix> <owner/repo> <rev>
266 # If the package vendors prism/rbs gems, re-read versions from upstream
267 # Makefile at that rev and refresh fetchurl hashes when they change.
268 local default_nix="$1" repo="$2" rev="$3"
269 python3 -c '
270import re, sys
271text = open(sys.argv[1]).read()
272sys.exit(0 if re.search(r"prismVersion\s*=", text) else 1)
273' "$default_nix" 2>/dev/null || return 0
274
275 local makefile
276 makefile="$(mktemp)"
277 local args=(-fsSL)
278 if [[ -n "${GITHUB_TOKEN:-}" ]]; then
279 args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
280 fi
281 curl "${args[@]}" \
282 "https://raw.githubusercontent.com/${repo}/${rev}/Makefile" \
283 -o "$makefile"
284
285 local prism_ver rbs_ver
286 prism_ver="$(python3 -c '
287import re, sys
288text = open(sys.argv[1]).read()
289m = re.search(r"PRISM_VERSION\s*\?=\s*(\S+)", text)
290print(m.group(1) if m else "")
291' "$makefile")"
292 rbs_ver="$(python3 -c '
293import re, sys
294text = open(sys.argv[1]).read()
295m = re.search(r"RBS_VERSION\s*\?=\s*(\S+)", text)
296print(m.group(1) if m else "")
297' "$makefile")"
298 rm -f "$makefile"
299
300 [[ -n "$prism_ver" && -n "$rbs_ver" ]] || {
301 warn "could not parse PRISM_VERSION/RBS_VERSION from upstream Makefile"
302 return 0
303 }
304
305 local cur_prism cur_rbs
306 cur_prism="$(python3 -c '
307import re, sys
308m = re.search(r"prismVersion\s*=\s*\"([^\"]+)\"", open(sys.argv[1]).read())
309print(m.group(1) if m else "")
310' "$default_nix")"
311 cur_rbs="$(python3 -c '
312import re, sys
313m = re.search(r"rbsVersion\s*=\s*\"([^\"]+)\"", open(sys.argv[1]).read())
314print(m.group(1) if m else "")
315' "$default_nix")"
316
317 if [[ "$prism_ver" != "$cur_prism" ]]; then
318 log " prism ${cur_prism} -> ${prism_ver}"
319 set_field_string "$default_nix" prismVersion "$prism_ver"
320 local prism_hash
321 prism_hash="$(sri_from_url_file "https://rubygems.org/gems/prism-${prism_ver}.gem")"
322 python3 -c '
323import re, sys
324path, ver, h = sys.argv[1], sys.argv[2], sys.argv[3]
325text = open(path).read()
326# first fetchurl after prismGem =
327pat = re.compile(
328 r"(prismGem\s*=\s*fetchurl\s*\{[^}]*?hash\s*=\s*\")([^\"]+)(\")",
329 re.S,
330)
331new, n = pat.subn(rf"\g<1>{h}\g<3>", text, count=1)
332if n != 1:
333 sys.exit(f"failed to set prismGem hash (matches={n})")
334open(path, "w").write(new)
335' "$default_nix" "$prism_ver" "$prism_hash"
336 log " prism gem hash ${prism_hash}"
337 fi
338
339 if [[ "$rbs_ver" != "$cur_rbs" ]]; then
340 log " rbs ${cur_rbs} -> ${rbs_ver}"
341 set_field_string "$default_nix" rbsVersion "$rbs_ver"
342 local rbs_hash
343 rbs_hash="$(sri_from_url_file "https://rubygems.org/gems/rbs-${rbs_ver}.gem")"
344 python3 -c '
345import re, sys
346path, h = sys.argv[1], sys.argv[2]
347text = open(path).read()
348pat = re.compile(
349 r"(rbsGem\s*=\s*fetchurl\s*\{[^}]*?hash\s*=\s*\")([^\"]+)(\")",
350 re.S,
351)
352new, n = pat.subn(rf"\g<1>{h}\g<3>", text, count=1)
353if n != 1:
354 sys.exit(f"failed to set rbsGem hash (matches={n})")
355open(path, "w").write(new)
356' "$default_nix" "$rbs_hash"
357 log " rbs gem hash ${rbs_hash}"
358 fi
359}
360
361update_npm_github() {
362 local name="$1"
363 local pkg_dir="$ROOT/packages/${name}"
364 local default_nix="$pkg_dir/default.nix"
365 local upstream="$pkg_dir/upstream.json"
366 local repo tag_prefix
367 repo="$(read_field "$upstream" github)"
368 tag_prefix="$(read_field "$upstream" tag_prefix)"
369 [[ -n "$repo" ]] || die "${name}: upstream.json missing github"
370 tag_prefix="${tag_prefix:-v}"
371
372 local cur latest
373 cur="$(current_version "$default_nix")"
374 log "${name}: current=${cur}"
375 latest="$(latest_github_tag "$repo" "$tag_prefix")"
376 log "${name}: latest upstream=${latest}"
377
378 if ! version_gt "$latest" "$cur"; then
379 log "${name}: already up to date"
380 return 0
381 fi
382
383 if [[ "$CHECK_ONLY" -eq 1 ]]; then
384 log "${name}: OUTDATED (${cur} -> ${latest})"
385 return 10
386 fi
387
388 log "${name}: updating ${cur} -> ${latest}"
389 set_field_string "$default_nix" version "$latest"
390
391 local src_url src_hash
392 src_url="https://github.com/${repo}/archive/refs/tags/${tag_prefix}${latest}.tar.gz"
393 log "${name}: prefetching src ${src_url}"
394 src_hash="$(sri_from_url_unpack "$src_url")"
395 set_fetch_from_github_field "$default_nix" hash "$src_hash"
396 log "${name}: src hash ${src_hash}"
397
398 if [[ -f "$pkg_dir/package-lock.json" ]]; then
399 log "${name}: regenerating package-lock.json"
400 regenerate_npm_lock "$repo" "$latest" "$pkg_dir/package-lock.json"
401 fi
402
403 log "${name}: computing npmDepsHash"
404 set_npm_deps_hash "$default_nix" "$FAKE_HASH"
405 local npm_hash
406 npm_hash="$(capture_npm_deps_hash "$name")"
407 set_npm_deps_hash "$default_nix" "$npm_hash"
408 log "${name}: npmDepsHash ${npm_hash}"
409
410 verify_build "$name"
411 log "${name}: updated successfully to ${latest}"
412}
413
414latest_github_tag_with_suffix() {
415 # latest_github_tag_with_suffix <owner/repo> <prefix> <suffix>
416 # e.g. prefix=v suffix=-pre => matches v1.12.0-pre, prints 1.12.0-pre
417 local repo="$1" prefix="$2" suffix="$3"
418 local url="https://api.github.com/repos/${repo}/releases?per_page=30"
419 local args=(-fsSL)
420 if [[ -n "${GITHUB_TOKEN:-}" ]]; then
421 args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
422 fi
423 curl "${args[@]}" "$url" | python3 -c '
424import json, re, sys
425prefix, suffix = sys.argv[1], sys.argv[2]
426releases = json.load(sys.stdin)
427pat = re.compile(
428 r"^" + re.escape(prefix) + r"(\d+\.\d+\.\d+)" + re.escape(suffix) + r"$"
429)
430versions = []
431for r in releases:
432 if r.get("draft"):
433 continue
434 name = r.get("tag_name") or ""
435 m = pat.match(name)
436 if m:
437 versions.append(m.group(1) + suffix)
438if not versions:
439 sys.exit("no matching release tags for prefix=%r suffix=%r" % (prefix, suffix))
440def key(v):
441 core = re.split(r"[+-]", v, maxsplit=1)[0]
442 return [int(x) for x in core.split(".")]
443versions.sort(key=key)
444print(versions[-1])
445' "$prefix" "$suffix"
446}
447
448set_fetchurl_hash() {
449 # set_fetchurl_hash <file> <value>
450 # Replaces hash = "..." inside the first fetchurl { ... } block.
451 local file="$1" value="$2"
452 python3 -c '
453import re, sys
454path, value = sys.argv[1], sys.argv[2]
455text = open(path).read()
456pat = re.compile(
457 r"(fetchurl\s*\{[^}]*?hash\s*=\s*\")([^\"]+)(\")",
458 re.S,
459)
460new, n = pat.subn(rf"\g<1>{value}\g<3>", text, count=1)
461if n != 1:
462 sys.exit(f"failed to set fetchurl.hash in {path} (matches={n})")
463open(path, "w").write(new)
464' "$file" "$value"
465}
466
467update_github_release_binary() {
468 # Official prebuilt release assets (e.g. zed-preview Linux tarball).
469 # version in default.nix is the tag without the leading "v" (e.g. 1.12.0-pre).
470 local name="$1"
471 local pkg_dir="$ROOT/packages/${name}"
472 local default_nix="$pkg_dir/default.nix"
473 local upstream="$pkg_dir/upstream.json"
474 local repo tag_prefix tag_suffix asset
475 repo="$(read_field "$upstream" github)"
476 tag_prefix="$(read_field "$upstream" tag_prefix)"
477 tag_suffix="$(read_field "$upstream" tag_suffix)"
478 asset="$(read_field "$upstream" asset)"
479 [[ -n "$repo" ]] || die "${name}: upstream.json missing github"
480 [[ -n "$asset" ]] || die "${name}: upstream.json missing asset"
481 tag_prefix="${tag_prefix:-v}"
482 tag_suffix="${tag_suffix:-}"
483
484 local cur latest
485 cur="$(current_version "$default_nix")"
486 log "${name}: current=${cur}"
487 latest="$(latest_github_tag_with_suffix "$repo" "$tag_prefix" "$tag_suffix")"
488 log "${name}: latest upstream=${latest}"
489
490 if [[ "$latest" == "$cur" ]]; then
491 log "${name}: already up to date"
492 return 0
493 fi
494
495 # Semver compare on the numeric core (ignore -pre etc.)
496 local cur_core latest_core
497 cur_core="${cur%%-*}"; cur_core="${cur_core%%+*}"
498 latest_core="${latest%%-*}"; latest_core="${latest_core%%+*}"
499 if ! version_gt "$latest_core" "$cur_core" && [[ "$latest" != "$cur" ]]; then
500 # Same core version but different suffix, or non-monotonic tag: still update
501 # when the full tag string differs (handled above). If latest core is older,
502 # skip to avoid downgrades.
503 if ! version_gt "$latest_core" "$cur_core" && [[ "$latest_core" != "$cur_core" ]]; then
504 log "${name}: latest core ${latest_core} is not newer than ${cur_core}; skipping"
505 return 0
506 fi
507 fi
508
509 if [[ "$CHECK_ONLY" -eq 1 ]]; then
510 log "${name}: OUTDATED (${cur} -> ${latest})"
511 return 10
512 fi
513
514 log "${name}: updating ${cur} -> ${latest}"
515 set_field_string "$default_nix" version "$latest"
516
517 local src_url src_hash
518 src_url="https://github.com/${repo}/releases/download/${tag_prefix}${latest}/${asset}"
519 log "${name}: prefetching ${src_url}"
520 src_hash="$(sri_from_url_file "$src_url")"
521 set_fetchurl_hash "$default_nix" "$src_hash"
522 log "${name}: src hash ${src_hash}"
523
524 verify_build "$name"
525 log "${name}: updated successfully to ${latest}"
526}
527
528update_github_unstable() {
529 # Track tip of a branch for projects without release tags.
530 # version format: 0-unstable-YYYY-MM-DD
531 local name="$1"
532 local pkg_dir="$ROOT/packages/${name}"
533 local default_nix="$pkg_dir/default.nix"
534 local upstream="$pkg_dir/upstream.json"
535 local repo branch
536 repo="$(read_field "$upstream" github)"
537 branch="$(read_field "$upstream" branch)"
538 [[ -n "$repo" ]] || die "${name}: upstream.json missing github"
539 branch="${branch:-master}"
540
541 local cur_rev cur_ver latest_sha latest_date latest_ver
542 cur_rev="$(current_rev "$default_nix")"
543 cur_ver="$(current_version "$default_nix")"
544 log "${name}: current=${cur_ver} (rev ${cur_rev:0:12})"
545
546 read -r latest_sha latest_date < <(latest_github_commit "$repo" "$branch")
547 latest_ver="0-unstable-${latest_date}"
548 log "${name}: latest ${branch}=${latest_sha:0:12} (${latest_date})"
549
550 if [[ "$latest_sha" == "$cur_rev" ]]; then
551 log "${name}: already up to date"
552 return 0
553 fi
554
555 if [[ "$CHECK_ONLY" -eq 1 ]]; then
556 log "${name}: OUTDATED (${cur_rev:0:12} -> ${latest_sha:0:12})"
557 return 10
558 fi
559
560 log "${name}: updating ${cur_rev:0:12} -> ${latest_sha:0:12}"
561 set_field_string "$default_nix" version "$latest_ver"
562 set_fetch_from_github_field "$default_nix" rev "$latest_sha"
563
564 local src_url src_hash
565 src_url="https://github.com/${repo}/archive/${latest_sha}.tar.gz"
566 log "${name}: prefetching src ${src_url}"
567 src_hash="$(sri_from_url_unpack "$src_url")"
568 set_fetch_from_github_field "$default_nix" hash "$src_hash"
569 log "${name}: src hash ${src_hash}"
570
571 refresh_vendored_gems "$default_nix" "$repo" "$latest_sha"
572
573 verify_build "$name"
574 log "${name}: updated successfully to ${latest_ver} (${latest_sha:0:12})"
575}
576
577UPDATED=0
578OUTDATED=0
579
580for name in "${PACKAGES[@]}"; do
581 pkg_dir="$ROOT/packages/${name}"
582 [[ -d "$pkg_dir" ]] || die "unknown package: ${name}"
583 [[ -f "$pkg_dir/upstream.json" ]] || die "${name}: missing packages/${name}/upstream.json"
584 [[ -f "$pkg_dir/default.nix" ]] || die "${name}: missing packages/${name}/default.nix"
585
586 type="$(read_field "$pkg_dir/upstream.json" type)"
587 status=0
588 case "$type" in
589 npm-github)
590 update_npm_github "$name" || status=$?
591 ;;
592 github-unstable)
593 update_github_unstable "$name" || status=$?
594 ;;
595 github-release-binary)
596 update_github_release_binary "$name" || status=$?
597 ;;
598 *)
599 die "${name}: unsupported upstream type '${type}'"
600 ;;
601 esac
602 if [[ $status -eq 10 ]]; then
603 OUTDATED=$((OUTDATED + 1))
604 elif [[ $status -ne 0 ]]; then
605 exit "$status"
606 else
607 # detect if files changed for this package
608 if [[ "$CHECK_ONLY" -eq 0 ]] && ! git -C "$ROOT" diff --quiet -- "packages/${name}" 2>/dev/null; then
609 UPDATED=$((UPDATED + 1))
610 fi
611 fi
612done
613
614if [[ "$CHECK_ONLY" -eq 1 ]]; then
615 if [[ "$OUTDATED" -gt 0 ]]; then
616 log "${OUTDATED} package(s) outdated"
617 exit 1
618 fi
619 log "all packages up to date"
620 exit 0
621fi
622
623log "done (${UPDATED} package dir(s) with local changes)"