Files
LibrePortal/scripts/release/make_release.sh
T
LibrePortal 670f519c93 Show the apps built into LibrePortal on the release catalog page
The catalog page at the release host only listed add-ons from the signed
index, and every current app ships inside LibrePortal itself, so a first
release would have shown an empty catalog. make_release.sh now also writes
the release's app list (apps.json, plus icons) next to latest.json, chosen
the same way as the website's app grid. The page shows those apps as
"Built in" with a `libreportal app install` command, and add-ons from the
index as before. A built-in app hides an add-on with the same name, as it
does on a box.

The list is display data only: boxes never read it. The Submissions badge
no longer shows as an empty dot when there are no submissions.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:55:05 +01:00

200 lines
9.2 KiB
Bash
Executable File

#!/bin/bash
#
# Build a versioned, checksum-verified LibrePortal release artifact.
#
# The stable install fetches a release tarball over plain HTTPS (no git, no auth),
# so it's reproducible and version-pinned. This builds that artifact from the
# COMMITTED tree via `git archive`, which honours the `export-ignore` rules in
# .gitattributes — so dev-only trees (scripts/unused, site, .claude, …) never ship.
#
# Signing is required. Every install carries the real public key and refuses an
# unsigned release, so this fails closed unless LP_MINISIGN_SECKEY is set. The
# output lands in dist/<channel>/ laid out exactly like the host serves it:
# libreportal-<v>.tar.gz(.sha256,.minisig) the release
# latest.json the channel pointer boxes follow
# apps.json + icons/ the built-in apps, for the catalog page
#
# For a local test without the key, pass --unsigned. That build goes to
# dist-unsigned/<channel>/ (never dist/), and the install needs --no-verify-signature:
# ( cd dist-unsigned && python3 -m http.server 8000 )
# LP_RELEASE_BASE_URL=http://localhost:8000 ./install.sh --no-verify-signature ...
#
# Usage: scripts/release/make_release.sh [--unsigned] [channel] [git-ref]
# channel stable (default) | edge
# git-ref HEAD (default) | a tag/commit to build from
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO_ROOT"
source "$REPO_ROOT/scripts/release/lib/release_index.sh"
ARGS=()
for arg in "$@"; do
case "$arg" in
--unsigned) export LP_RELEASE_UNSIGNED=1 ;;
-*) echo "make_release: unknown option $arg" >&2; exit 1 ;;
*) ARGS+=("$arg") ;;
esac
done
CHANNEL="${ARGS[0]:-stable}"
REF="${ARGS[1]:-HEAD}"
[[ "$CHANNEL" =~ ^[a-z0-9_-]+$ ]] || { echo "make_release: bad channel name '$CHANNEL'" >&2; exit 1; }
releaseRequireSigning make_release
command -v jq >/dev/null 2>&1 || { echo "make_release: jq is required" >&2; exit 1; }
VERSION="$(tr -d ' \t\n\r' < VERSION 2>/dev/null || true)"
[[ -n "$VERSION" ]] || { echo "make_release: VERSION file is empty or missing" >&2; exit 1; }
# Root-owned-footprint version (helpers/wrapper/unit/sudoers). Published in the
# manifest so the updater can detect when an update needs a root re-install.
FOOTPRINT_VERSION="$(grep -oE '^footprint_version=[0-9]+' init.sh | head -1 | cut -d= -f2)"
[[ -n "$FOOTPRINT_VERSION" ]] || FOOTPRINT_VERSION=0
# Guard: a release must never ship stale source arrays. Regenerate them; if that
# changes anything not committed, the tree was stale — abort and tell the dev to
# commit, so `git archive` (committed files only) can't bake in a mismatch. Only
# enforced when building HEAD (an explicit old tag/ref is taken as-is).
if [[ "$REF" == "HEAD" && -x scripts/source/files/generate_arrays.sh ]]; then
scripts/source/files/generate_arrays.sh run >/dev/null 2>&1 || true
if ! git diff --quiet -- scripts/source/files/arrays 2>/dev/null; then
echo "make_release: source arrays were stale — regenerated them now." >&2
echo " Commit the changes under scripts/source/files/arrays/ and re-run." >&2
exit 1
fi
fi
TARBALL="libreportal-${VERSION}.tar.gz"
PREFIX="libreportal-${VERSION}/" # top-level dir inside the tarball
OUT="$(releaseDistDir)/$CHANNEL"
mkdir -p "$OUT"
# A published version is immutable: the host serves tarballs with a one-year
# immutable cache, and boxes pin its sha256. Rebuilding the same version would
# swap the bytes under everyone, so a signed build refuses; bump VERSION instead.
# Unsigned test builds may overwrite (they never leave this machine).
if ! releaseUnsigned && [[ -e "$OUT/$TARBALL" ]]; then
echo "make_release: $OUT/$TARBALL already exists — bump VERSION for a new release." >&2
exit 1
fi
echo "Building $TARBALL (channel=$CHANNEL, ref=$REF) ..."
# Build into a staging tree (not a streamed tarball) so we can drop a per-file
# integrity manifest INSIDE the release. `git archive --format=tar` still honours
# .gitattributes export-ignore, so dev-only trees stay out of the staging tree.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
git archive --format=tar --prefix="$PREFIX" "$REF" | tar -x -C "$STAGE"
# SHA256SUMS: one `sha256sum`-format line per shipped file (relative ./paths,
# stable sort), excluding the manifest + its signature themselves. The running
# install re-hashes against this to prove its files match the signed release
# (see lpVerifyInstall in scripts/source/verify.sh). The manifest's own trust
# comes from SHA256SUMS.minisig below, not from being in the list.
(
cd "$STAGE/$PREFIX"
find . -type f ! -name SHA256SUMS ! -name SHA256SUMS.minisig -print0 \
| LC_ALL=C sort -z | xargs -0 sha256sum > SHA256SUMS
)
MANIFEST_FILES="$(wc -l < "$STAGE/$PREFIX/SHA256SUMS" | tr -d ' ')"
# Sign the manifest with the same offline minisign key used for the tarball, so
# the install can verify it against the root-owned public key. Keep
# LP_MINISIGN_SECKEY on the release machine only.
releaseSignIfKeyed "$STAGE/$PREFIX/SHA256SUMS" "libreportal $VERSION manifest ($CHANNEL)"
MANIFEST_SIGNED=" SHA256SUMS: $(releaseSignedNote) (inside tarball)"
# Everything is built and signed in BUILD first and moved into OUT at the end,
# latest.json last. A failure part-way (a wrong key, a full disk) then leaves the
# channel exactly as it was, instead of a latest.json naming a tarball that has
# no signature yet.
BUILD="$STAGE/.out"
mkdir -p "$BUILD"
# Pack the staging tree (manifest + signature included). --sort/--owner/--group
# keep the archive deterministic for a given commit.
tar --sort=name --owner=0 --group=0 --numeric-owner -czf "$BUILD/$TARBALL" -C "$STAGE" "$PREFIX"
( cd "$BUILD" && sha256sum "$TARBALL" > "$TARBALL.sha256" )
SHA="$(cut -d' ' -f1 < "$BUILD/$TARBALL.sha256")"
# apps.json + icons/: the apps BUILT INTO this release, for the catalog page at
# the release host (containers/libreportal_catalog), which otherwise only knows
# the add-ons in index.json. Display data only: boxes never read it, and the
# page trusts nothing it shows (the tarball's signature covers the real thing).
# Same selection as the website's app grid: a TITLE, not DEV_ONLY, not an
# instance, not the WebUI itself. Metadata is parsed line-wise, never sourced.
mkdir -p "$BUILD/icons"
APPS_JSON='[]'
for cfg in "$STAGE/$PREFIX"containers/*/*.config; do
slug="$(basename "$(dirname "$cfg")")"
[[ "$cfg" == */"$slug/$slug.config" && "$slug" != "libreportal" ]] || continue
title=""; category=""; description=""; long_description=""; skip=0
while IFS='=' read -r key val || [[ -n "$key" ]]; do
[[ "$key" == "CFG_${slug^^}_"* ]] || continue
val="${val%$'\r'}"
if [[ "$val" == \"* ]]; then val="${val#\"}"; val="${val%%\"*}"
else val="${val%%[[:space:]]#*}"; val="${val%"${val##*[![:space:]]}"}"; fi
case "${key#CFG_"${slug^^}"_}" in
TITLE) title="$val" ;;
CATEGORY) category="${val%%,*}" ;;
DESCRIPTION) description="$val" ;;
LONG_DESCRIPTION) long_description="$val" ;;
DEV_ONLY) [[ "${val,,}" == "true" ]] && skip=1 ;;
INSTANCE_OF) [[ -n "$val" ]] && skip=1 ;;
esac
done < "$cfg"
[[ -n "$title" && "$skip" == 0 ]] || continue
icon=""
for ext in svg png; do
if [[ -f "$(dirname "$cfg")/$slug.$ext" ]]; then
cp "$(dirname "$cfg")/$slug.$ext" "$BUILD/icons/$slug.$ext"
icon="$CHANNEL/icons/$slug.$ext"
break
fi
done
APPS_JSON="$(jq -c --arg slug "$slug" --arg title "$title" --arg category "${category,,}" \
--arg description "$description" --arg long_description "$long_description" --arg icon "$icon" \
'. + [{slug:$slug, title:$title, category:$category, description:$description,
long_description:$long_description} + (if $icon != "" then {icon:$icon} else {} end)]' <<<"$APPS_JSON")"
done
jq --arg version "$VERSION" --arg channel "$CHANNEL" \
'{schema:1, version:$version, channel:$channel, apps:(sort_by(.title | ascii_downcase))}' \
<<<"$APPS_JSON" > "$BUILD/apps.json"
APP_COUNT="$(jq '.apps | length' "$BUILD/apps.json")"
cat > "$BUILD/latest.json" <<EOF
{
"version": "$VERSION",
"channel": "$CHANNEL",
"url": "$TARBALL",
"sha256": "$SHA",
"footprint_version": $FOOTPRINT_VERSION,
"notes": ""
}
EOF
# Sign the tarball (and check the signature against libreportal.pub, the key
# install.sh verifies with). Keep LP_MINISIGN_SECKEY OFFLINE, on the release
# machine only. Produces <tarball>.minisig.
releaseSignIfKeyed "$BUILD/$TARBALL" "libreportal $VERSION ($CHANNEL)"
SIGNED=" tarball: $(releaseSignedNote)"
# Into place: the immutable files first, the channel pointer last.
mv -f "$BUILD/$TARBALL" "$OUT/$TARBALL"
[[ -f "$BUILD/$TARBALL.minisig" ]] && mv -f "$BUILD/$TARBALL.minisig" "$OUT/$TARBALL.minisig"
mv -f "$BUILD/$TARBALL.sha256" "$OUT/$TARBALL.sha256"
mkdir -p "$OUT/icons"
cp -rf "$BUILD/icons/." "$OUT/icons/"
mv -f "$BUILD/apps.json" "$OUT/apps.json"
mv -f "$BUILD/latest.json" "$OUT/latest.json"
echo "✓ $OUT/$TARBALL"
echo "✓ $OUT/$TARBALL.sha256 ($SHA)"
echo "✓ $OUT/apps.json ($APP_COUNT built-in apps, for the catalog page)"
echo "✓ $OUT/latest.json"
echo "$SIGNED"
echo " ✓ SHA256SUMS ($MANIFEST_FILES files, inside tarball)"
echo "$MANIFEST_SIGNED"