librelad 8351863719 feat(release): make_app.sh — publish app definition bundles to the artifact index
The marketplace publisher tool: packs containers/<slug>/ (the drop-in app
contract, unchanged) into a deterministic tarball (commit-clamped mtimes +
gzip -n; unchanged apps repack byte-identical and keep their published
version), signs it, copies a sha256-pinned catalog icon, and upserts a
type:"app" / payload.kind:"bundle" envelope into the same team-signed
index hotfixes ride. Catalog metadata (title/category/descriptions) is
parsed line-wise from the app's own .config — one source of truth with the
App Center generators. auto:false is hard-forced: apps never auto-apply.

The index-upsert/serial/freshness/publishers-map and signing logic is
factored out of make_hotfix.sh into lib/release_index.sh, shared by both
tools (make_hotfix.sh behavior preserved; regression-tested alongside an
app entry: serial bump, both payload kinds coexist, valid_until refreshed).
LP_INDEX_VALID_DAYS is the shared freshness knob (LP_HOTFIX_VALID_DAYS
kept as a legacy alias).

Verified: speedtest publish → deterministic repack (identical sha256) →
served via local http.server → real lpFetchIndex/accessors harness 10/10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-03 20:45:02 +01:00

187 lines
9.6 KiB
Bash

#!/bin/bash
#
# Publish a signed LibrePortal APP BUNDLE into the channel's artifact index.
#
# An app bundle is the drop-in definition folder (containers/<slug>/ — see
# docs/contributing/development.md "an app is a self-contained drop-in") packed
# into a deterministic tarball and catalogued as a type:"app" envelope in the
# same team-signed index hotfixes ride (docs/roadmap/updates-and-distribution.md
# §8). Boxes surface it in the App Center as an "Available — Add" card;
# `libreportal app add <slug>` verifies + drops the definition in.
#
# dist/<channel>/payloads/<id>.tar.gz(.minisig) the app definition bundle
# dist/<channel>/payloads/icons/<slug>.<ext> sha256-pinned catalog icon
# dist/<channel>/index.json(.minisig) the catalog (entry upserted, serial bumped)
# laid out exactly like the host serves it, so you can test locally:
# ( cd dist && python3 -m http.server 8000 )
# LP_RELEASE_BASE_URL=http://localhost:8000 libreportal artifact index
#
# Usage: scripts/release/make_app.sh <app-slug> [channel] [spec.json]
# app-slug folder name under containers/ (e.g. speedtest)
# channel stable (default) | edge
# spec.json optional overlay for envelope fields the .config can't provide:
# { "id", "version", "why", "severity", "supersedes": [],
# "publisher", "trust", "applies_when": {"min_lp","max_lp","max_footprint"} }
# Defaults: id=app-<slug>, version=auto-bump, severity=tweak,
# publisher=libreportal, trust=official. `auto` is ALWAYS false —
# apps are never auto-applied; the box enforces the same rule.
#
# Catalog metadata (title/category/description/long description) comes from the
# app's own .config — one source of truth with the App Center generators.
#
# Env: LP_MINISIGN_SECKEY / LP_MINISIGN_PUBKEY / LP_INDEX_VALID_DAYS — see
# lib/release_index.sh.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$REPO_ROOT/scripts/release/lib/release_index.sh"
SLUG="${1:-}"
CHANNEL="${2:-stable}"
SPEC="${3:-}"
[[ -n "$SLUG" ]] || { echo "make_app: usage: make_app.sh <app-slug> [channel] [spec.json]" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "make_app: jq is required" >&2; exit 1; }
# Slug contract (matches the box-side bundle validator): lowercase shell-safe
# folder name — CFG_<SLUG^^>_* must be a valid shell identifier, so no dashes.
[[ "$SLUG" =~ ^[a-z0-9][a-z0-9_]{0,31}$ ]] || { echo "make_app: slug '$SLUG' must match ^[a-z0-9][a-z0-9_]{0,31}\$" >&2; exit 1; }
case " template libreportal tools scripts resources " in
*" $SLUG "*) echo "make_app: slug '$SLUG' is reserved" >&2; exit 1 ;;
esac
APP_DIR="$REPO_ROOT/containers/$SLUG"
CONFIG_FILE="$APP_DIR/$SLUG.config"
[[ -d "$APP_DIR" ]] || { echo "make_app: containers/$SLUG/ not found" >&2; exit 1; }
[[ -f "$CONFIG_FILE" ]] || { echo "make_app: $SLUG.config not found — not an app definition" >&2; exit 1; }
[[ -f "$APP_DIR/docker-compose.yml" ]] || { echo "make_app: docker-compose.yml not found — not an app definition" >&2; exit 1; }
if [[ -n "$SPEC" ]]; then
[[ -f "$SPEC" ]] || { echo "make_app: spec file not found: $SPEC" >&2; exit 1; }
jq empty "$SPEC" 2>/dev/null || { echo "make_app: $SPEC is not valid JSON" >&2; exit 1; }
fi
# --- catalog metadata from the app's .config (line-wise, never sourced) -------
# Same parse the App Center generator uses (webui_config.sh): strip quotes/CR,
# trim whitespace; TITLE + CATEGORY are what make a definition recognizable.
APP_UPPER="${SLUG^^}"
TITLE=""; DESCRIPTION=""; LONG_DESCRIPTION=""; CATEGORY=""
while IFS='=' read -r var_name var_value || [[ -n "$var_name" ]]; do
[[ -z "$var_name" || "$var_name" =~ ^[[:space:]]*# ]] && continue
[[ "$var_name" == CFG_${APP_UPPER}_* ]] || continue
var_value="${var_value#\"}"; var_value="${var_value%\"}"
var_value="${var_value//$'\r'/}"
var_value="${var_value#"${var_value%%[![:space:]]*}"}"
var_value="${var_value%"${var_value##*[![:space:]]}"}"
case "$var_name" in
"CFG_${APP_UPPER}_TITLE") TITLE="$var_value" ;;
"CFG_${APP_UPPER}_DESCRIPTION") DESCRIPTION="$var_value" ;;
"CFG_${APP_UPPER}_LONG_DESCRIPTION") LONG_DESCRIPTION="$var_value" ;;
"CFG_${APP_UPPER}_CATEGORY") CATEGORY="$var_value" ;;
esac
done < "$CONFIG_FILE"
[[ -n "$TITLE" && -n "$CATEGORY" ]] || { echo "make_app: $SLUG.config must set CFG_${APP_UPPER}_TITLE and CFG_${APP_UPPER}_CATEGORY" >&2; exit 1; }
OUT="$REPO_ROOT/dist/$CHANNEL"
PAYDIR="$OUT/payloads"
ICONDIR="$PAYDIR/icons"
mkdir -p "$PAYDIR" "$ICONDIR"
INDEX="$OUT/index.json"
# --- envelope fields (spec overlay over derived defaults) ---------------------
specget() { [[ -n "$SPEC" ]] && jq -r "$1 // empty" "$SPEC" || echo ""; }
ID="$(specget '.id')"; ID="${ID:-app-$SLUG}"
[[ "$ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "make_app: id '$ID' has unsafe characters" >&2; exit 1; }
PUBLISHER="$(specget '.publisher')"; PUBLISHER="${PUBLISHER:-libreportal}"
TRUST="$(specget '.trust')"; TRUST="${TRUST:-official}"
SEVERITY="$(specget '.severity')"; SEVERITY="${SEVERITY:-tweak}"
WHY="$(specget '.why')"; WHY="${WHY:-$DESCRIPTION}"; WHY="${WHY:-Adds $TITLE to the app catalog.}"
SUPERSEDES="[]"; [[ -n "$SPEC" ]] && SUPERSEDES="$(jq -c '.supersedes // []' "$SPEC")"
# version: explicit in the spec, else auto — bump the existing catalog entry
# only when the packed bundle actually changed (resolved after packing).
VERSION_NUM="$(specget '.version')"
[[ -z "$VERSION_NUM" || "$VERSION_NUM" =~ ^[0-9]+$ ]] || { echo "make_app: version must be an integer" >&2; exit 1; }
# applies_when: spec gates kept, but .app is always the slug being packed.
APPLIES='{}'; [[ -n "$SPEC" ]] && APPLIES="$(jq -c '.applies_when // {}' "$SPEC")"
APPLIES="$(jq -c --arg app "$SLUG" '.app = $app' <<<"$APPLIES")"
# --- pack the bundle (deterministic for a given commit) ------------------------
# Top-level dir inside the tarball == the slug (the box validator requires it).
# mtimes are clamped to the app tree's last commit so re-packing an unchanged
# app yields byte-identical output; gzip -n keeps the stream timestamp-free.
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp -a "$APP_DIR" "$STAGE/$SLUG"
EPOCH="$(git -C "$REPO_ROOT" log -1 --format=%ct -- "containers/$SLUG" 2>/dev/null || true)"
[[ -n "$EPOCH" ]] || EPOCH="$(date +%s)"
PAYLOAD="$PAYDIR/$ID.tar.gz"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$EPOCH" \
-cf - -C "$STAGE" "$SLUG" | gzip -n > "$PAYLOAD"
SHA="$(sha256sum "$PAYLOAD" | cut -d' ' -f1)"
SIGNED_NOTE="$(releaseSignedNote)"
releaseSignIfKeyed "$PAYLOAD" "libreportal app $ID ($CHANNEL)"
# auto-version: same bundle bytes keep the published version; new bytes bump it.
if [[ -z "$VERSION_NUM" ]]; then
VERSION_NUM=1
if [[ -f "$INDEX" ]]; then
prev="$(jq -r --arg id "$ID" '[.artifacts[]? | select(.id==$id)] | last | .version // 0' "$INDEX")"
prev_sha="$(jq -r --arg id "$ID" '[.artifacts[]? | select(.id==$id)] | last | .payload.sha256 // ""' "$INDEX")"
if (( prev > 0 )); then
VERSION_NUM="$prev"
[[ "$prev_sha" != "$SHA" ]] && VERSION_NUM=$(( prev + 1 ))
fi
fi
fi
# --- catalog icon (sha256-pinned; the box scan stores it locally) --------------
META_ICON=""; META_ICON_SHA=""
for ext in svg png; do
if [[ -f "$APP_DIR/$SLUG.$ext" ]]; then
cp "$APP_DIR/$SLUG.$ext" "$ICONDIR/$SLUG.$ext"
META_ICON="$CHANNEL/payloads/icons/$SLUG.$ext"
META_ICON_SHA="$(sha256sum "$ICONDIR/$SLUG.$ext" | cut -d' ' -f1)"
break
fi
done
META="$(jq -cn \
--arg category "$CATEGORY" --arg description "$DESCRIPTION" \
--arg long_description "$LONG_DESCRIPTION" \
--arg icon "$META_ICON" --arg icon_sha "$META_ICON_SHA" '
{category:$category, description:$description, long_description:$long_description}
+ (if $icon != "" then {icon:$icon, icon_sha256:$icon_sha} else {} end)')"
# --- build the envelope (§8.1 shape; type:"app", payload.kind:"bundle") -------
envelope="$(jq -cn \
--arg id "$ID" --argjson version "$VERSION_NUM" --argjson supersedes "$SUPERSEDES" \
--arg publisher "$PUBLISHER" --arg trust "$TRUST" --arg severity "$SEVERITY" \
--arg title "$TITLE" --arg why "$WHY" --argjson applies "$APPLIES" \
--arg url "$CHANNEL/payloads/$ID.tar.gz" --arg sha "$SHA" \
--arg sig "$CHANNEL/payloads/$ID.tar.gz.minisig" --argjson meta "$META" '
{ id:$id, type:"app", version:$version, supersedes:$supersedes,
reversible:true, publisher:$publisher, trust:$trust, severity:$severity,
auto:false, title:$title, why:$why, applies_when:$applies,
payload:{kind:"bundle", url:$url, sha256:$sha, sig:$sig}, meta:$meta }')"
# --- the publisher's public key for the index publishers map -------------------
PUBKEY_FILE="${LP_MINISIGN_PUBKEY:-$REPO_ROOT/libreportal.pub}"
PUBKEY="$(releaseReadPubkey "$PUBKEY_FILE")"
# --- upsert into the index (serial bump + freshness), then sign it -------------
serial="$(releaseIndexUpsert "$envelope" "$INDEX" "$PUBLISHER" "$PUBKEY")"
releaseSignIfKeyed "$INDEX" "libreportal artifact index serial $serial ($CHANNEL)"
echo "$PAYLOAD ($SHA)"
[[ -n "$META_ICON" ]] && echo "$ICONDIR/${META_ICON##*/} (pinned)"
echo "$INDEX (serial=$serial, entries=$(jq '.artifacts | length' "$INDEX"))"
echo " $ID: $TITLE [$CATEGORY] v$VERSION_NUM"
echo " bundle + index: $SIGNED_NOTE"
[[ -z "$PUBKEY" ]] && echo " ⚠ no publisher pubkey found ($PUBKEY_FILE) — publishers.$PUBLISHER.key is empty"
echo ""
echo "Test locally:"
echo " ( cd '$REPO_ROOT/dist' && python3 -m http.server 8000 )"
echo " LP_RELEASE_BASE_URL=http://localhost:8000 libreportal artifact index"