#!/bin/bash # # Publish a signed LibrePortal APP BUNDLE into the channel's artifact index. # # An app bundle is the drop-in definition folder (containers// — 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 ` verifies + drops the definition in. # # dist//payloads/.tar.gz(.minisig) the app definition bundle # dist//payloads/icons/. sha256-pinned catalog icon # dist//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 [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-, 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 [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__* 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"