LibrePortal/scripts/release/make_hotfix.sh
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

114 lines
5.4 KiB
Bash

#!/bin/bash
#
# Publish a signed LibrePortal HOTFIX into the channel's artifact index.
#
# A hotfix is one entry in the team-signed index that `lpFetchIndex` pulls and
# `libreportal artifact apply` applies reversibly (see docs/roadmap/updates-and-
# distribution.md). This tool turns a small SPEC file into the two signed
# artifacts the install verifies:
# dist/<channel>/payloads/<id>.json(.minisig) the bounded declarative op list
# dist/<channel>/index.json(.minisig) the catalog (entry upserted, serial bumped)
# laid out exactly like the host (get.libreportal.org) 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_hotfix.sh <spec.json> [channel]
# spec.json the hotfix envelope + an embedded "ops" array (see below)
# channel stable (default) | edge
#
# Env: LP_MINISIGN_SECKEY / LP_MINISIGN_PUBKEY / LP_INDEX_VALID_DAYS — see
# lib/release_index.sh (LP_HOTFIX_VALID_DAYS still honoured as a legacy alias).
#
# Spec shape (the maintainer writes this):
# {
# "id": "hf-vaultwarden-signup-env-2026-05", // stable, unique
# "type": "hotfix",
# "version": 1,
# "supersedes": [],
# "reversible": true,
# "publisher": "libreportal",
# "trust": "official",
# "severity": "breakage", // security|breakage|compat|tweak
# "auto": true,
# "title": "...", "why": "...",
# "applies_when": { "app": "vaultwarden", "min_lp": "1.0.0", "max_footprint": 4 },
# "ops": [ { "op": "set-config-key", "key": "CFG_...", "value": "...", "expect_current": "..." } ]
# }
# An op may use "content_file": "<path>" instead of "content_b64" (for set-data-file /
# patch-file-if-checksum-matches) — this tool base64-encodes the file for you.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$REPO_ROOT/scripts/release/lib/release_index.sh"
SPEC="${1:-}"
CHANNEL="${2:-stable}"
[[ -n "$SPEC" && -f "$SPEC" ]] || { echo "make_hotfix: usage: make_hotfix.sh <spec.json> [channel]" >&2; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "make_hotfix: jq is required" >&2; exit 1; }
jq empty "$SPEC" 2>/dev/null || { echo "make_hotfix: $SPEC is not valid JSON" >&2; exit 1; }
OUT="$REPO_ROOT/dist/$CHANNEL"
PAYDIR="$OUT/payloads"
mkdir -p "$PAYDIR"
SPEC_DIR="$(cd "$(dirname "$SPEC")" && pwd)"
ID="$(jq -r '.id // empty' "$SPEC")"
[[ "$ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "make_hotfix: spec .id missing or has unsafe characters" >&2; exit 1; }
[[ "$(jq -r '.type // "hotfix"' "$SPEC")" == "hotfix" ]] || { echo "make_hotfix: only type 'hotfix' is supported" >&2; exit 1; }
jq -e '.ops | type == "array" and length > 0' "$SPEC" >/dev/null 2>&1 || { echo "make_hotfix: spec .ops must be a non-empty array" >&2; exit 1; }
# Validate every op name against the applier's CLOSED allowlist, so a typo can't
# ship an artifact that fails-closed on every box.
ALLOWED="set-config-key set-compose-image patch-file-if-checksum-matches set-data-file"
while IFS= read -r opname; do
case " $ALLOWED " in *" $opname "*) : ;; *)
echo "make_hotfix: op '$opname' is not in the supported vocabulary ($ALLOWED)" >&2; exit 1 ;;
esac
done < <(jq -r '.ops[].op // empty' "$SPEC")
# --- build the payload (ops), inlining any content_file -> content_b64 --------
ops="$(jq -c '.ops' "$SPEC")"
count="$(jq 'length' <<<"$ops")"
new_ops="[]"
for (( i=0; i<count; i++ )); do
op="$(jq -c ".[$i]" <<<"$ops")"
cf="$(jq -r '.content_file // empty' <<<"$op")"
if [[ -n "$cf" ]]; then
[[ "$cf" = /* ]] || cf="$SPEC_DIR/$cf"
[[ -f "$cf" ]] || { echo "make_hotfix: content_file not found: $cf" >&2; exit 1; }
b64="$(base64 -w0 < "$cf")"
op="$(jq -c --arg b "$b64" 'del(.content_file) | .content_b64 = $b' <<<"$op")"
fi
new_ops="$(jq -c --argjson o "$op" '. + [$o]' <<<"$new_ops")"
done
PAYLOAD="$PAYDIR/$ID.json"
jq -n --argjson ops "$new_ops" '{schema:1, ops:$ops}' > "$PAYLOAD"
SHA="$(sha256sum "$PAYLOAD" | cut -d' ' -f1)"
# --- sign the payload (if a key is configured) --------------------------------
SIGNED_NOTE="$(releaseSignedNote)"
releaseSignIfKeyed "$PAYLOAD" "libreportal hotfix $ID ($CHANNEL)"
# --- the publisher's public key for the index publishers map ------------------
PUBKEY_FILE="${LP_MINISIGN_PUBKEY:-$REPO_ROOT/libreportal.pub}"
PUBKEY="$(releaseReadPubkey "$PUBKEY_FILE")"
PUBLISHER="$(jq -r '.publisher // "libreportal"' "$SPEC")"
# --- build the envelope (spec minus ops, plus a signed payload ref) -----------
envelope="$(jq -c \
--arg url "$CHANNEL/payloads/$ID.json" --arg sha "$SHA" --arg sig "$CHANNEL/payloads/$ID.json.minisig" \
'del(.ops) | .payload = {kind:"ops", url:$url, sha256:$sha, sig:$sig}' "$SPEC")"
# --- upsert into the index (serial bump + freshness), then sign it ------------
INDEX="$OUT/index.json"
serial="$(releaseIndexUpsert "$envelope" "$INDEX" "$PUBLISHER" "$PUBKEY")"
releaseSignIfKeyed "$INDEX" "libreportal artifact index serial $serial ($CHANNEL)"
echo "$PAYLOAD ($SHA)"
echo "$INDEX (serial=$serial, entries=$(jq '.artifacts | length' "$INDEX"))"
echo " payload + 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"