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>
187 lines
9.6 KiB
Bash
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"
|