Merge claude/2

This commit is contained in:
librelad 2026-07-03 20:45:02 +01:00
commit e04fdbf64f
3 changed files with 266 additions and 44 deletions

View File

@ -0,0 +1,71 @@
# Shared publisher-side helpers for the artifact index (sourced by
# make_hotfix.sh / make_app.sh — release tooling only, never the CLI tree).
#
# The index is the team-signed catalog every install fetches + verifies
# (scripts/source/artifacts.sh, docs/roadmap/updates-and-distribution.md §8).
# These helpers own the parts every publisher tool shares: signing when a key
# is configured, reading the publisher pubkey for the publishers map, and the
# upsert-entry + bump-serial + refresh-freshness index rewrite.
#
# Env (shared by all publisher tools):
# LP_MINISIGN_SECKEY offline secret key — set to SIGN (required for a real
# publish; unset = unsigned, local-testing only).
# LP_MINISIGN_PUBKEY public key file for the publishers map (default: repo
# libreportal.pub). Installs verify against the
# ROOT-OWNED footprint key, so this must be that key.
# LP_INDEX_VALID_DAYS freshness window written to valid_until (default 30;
# LP_HOTFIX_VALID_DAYS honoured as a legacy alias).
# releaseSignIfKeyed <file> <trusted-comment> — minisign-sign in place when a
# key is configured; a no-op otherwise (unsigned local-testing mode).
releaseSignIfKeyed() {
[[ -n "${LP_MINISIGN_SECKEY:-}" ]] || return 0
command -v minisign >/dev/null 2>&1 || { echo "release: LP_MINISIGN_SECKEY set but 'minisign' isn't installed" >&2; exit 1; }
minisign -Sm "$1" -s "$LP_MINISIGN_SECKEY" -t "$2" >/dev/null
}
# releaseSignedNote — the human-readable signing status for the summary line.
releaseSignedNote() {
if [[ -n "${LP_MINISIGN_SECKEY:-}" ]]; then echo "signed"; else
echo "unsigned (set LP_MINISIGN_SECKEY to sign — required for a real publish)"
fi
}
# releaseReadPubkey <pubkey-file> — echo the bare key line for the publishers
# map ("" when the file is missing; callers warn, signing still proceeds).
releaseReadPubkey() {
[[ -f "${1:-}" ]] || { echo ""; return 0; }
grep -v -i '^untrusted comment' "$1" | head -1 | tr -d ' \t\r\n'
}
# releaseIndexUpsert <envelope-json> <index-file> <publisher> <pubkey>
# Load-or-init the index, replace any same-id entry with the envelope, bump
# index_serial, refresh valid_until/generated_at, and upsert the publisher
# into the publishers map (role: official for "libreportal", else community).
# Echoes the new serial (callers sign the index with it in the comment).
releaseIndexUpsert() {
local envelope="$1" index="$2" publisher="$3" pubkey="$4"
local cur serial days valid_until now default_role
cur='{"schema":1,"index_serial":0,"publishers":{},"artifacts":[]}'
[[ -f "$index" ]] && cur="$(cat "$index")"
serial=$(( $(jq -r '.index_serial // 0' <<<"$cur") + 1 ))
days="${LP_INDEX_VALID_DAYS:-${LP_HOTFIX_VALID_DAYS:-30}}"
valid_until=$(( $(date +%s) + days * 86400 ))
now="$(date -Iseconds 2>/dev/null || date)"
default_role="community"; [[ "$publisher" == "libreportal" ]] && default_role="official"
jq \
--argjson env "$envelope" \
--arg pub "$publisher" --arg pubkey "$pubkey" --arg role "$default_role" \
--argjson serial "$serial" --argjson vu "$valid_until" --arg now "$now" '
.schema = 1
| .index_serial = $serial
| .valid_until = $vu
| .generated_at = $now
| .publishers = (.publishers // {})
| .publishers[$pub] = ((.publishers[$pub] // {display: $pub, role: $role})
+ (if $pubkey != "" then {key: $pubkey} else {} end))
| .artifacts = (((.artifacts // []) | map(select(.id != $env.id))) + [$env])
' <<<"$cur" > "$index"
echo "$serial"
}

186
scripts/release/make_app.sh Normal file
View File

@ -0,0 +1,186 @@
#!/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"

View File

@ -16,13 +16,8 @@
# spec.json the hotfix envelope + an embedded "ops" array (see below)
# channel stable (default) | edge
#
# Env:
# LP_MINISIGN_SECKEY offline secret key — set to SIGN (required for a real
# publish; unset = unsigned, local-testing only).
# LP_MINISIGN_PUBKEY public key file for the publishers map (default: repo
# libreportal.pub). The install verifies the index against
# the ROOT-OWNED footprint key, so this must be that key.
# LP_HOTFIX_VALID_DAYS freshness window written to valid_until (default 30).
# 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):
# {
@ -44,6 +39,7 @@
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; }
@ -89,19 +85,12 @@ 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="unsigned (set LP_MINISIGN_SECKEY to sign — required for a real publish)"
if [[ -n "${LP_MINISIGN_SECKEY:-}" ]]; then
command -v minisign >/dev/null 2>&1 || { echo "make_hotfix: LP_MINISIGN_SECKEY set but 'minisign' isn't installed" >&2; exit 1; }
minisign -Sm "$PAYLOAD" -s "$LP_MINISIGN_SECKEY" -t "libreportal hotfix $ID ($CHANNEL)" >/dev/null
SIGNED_NOTE="signed"
fi
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=""
if [[ -f "$PUBKEY_FILE" ]]; then
PUBKEY="$(grep -v -i '^untrusted comment' "$PUBKEY_FILE" | head -1 | tr -d ' \t\r\n')"
fi
PUBKEY="$(releaseReadPubkey "$PUBKEY_FILE")"
PUBLISHER="$(jq -r '.publisher // "libreportal"' "$SPEC")"
# --- build the envelope (spec minus ops, plus a signed payload ref) -----------
@ -109,34 +98,10 @@ 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, bump serial, refresh freshness --------------------
# --- upsert into the index (serial bump + freshness), then sign it ------------
INDEX="$OUT/index.json"
cur='{"schema":1,"index_serial":0,"publishers":{},"artifacts":[]}'
[[ -f "$INDEX" ]] && cur="$(cat "$INDEX")"
serial=$(( $(jq -r '.index_serial // 0' <<<"$cur") + 1 ))
days="${LP_HOTFIX_VALID_DAYS:-30}"
valid_until=$(( $(date +%s) + days * 86400 ))
now="$(date -Iseconds 2>/dev/null || date)"
default_role="community"; [[ "$PUBLISHER" == "libreportal" ]] && default_role="official"
jq \
--argjson env "$envelope" \
--arg pub "$PUBLISHER" --arg pubkey "$PUBKEY" --arg role "$default_role" \
--argjson serial "$serial" --argjson vu "$valid_until" --arg now "$now" '
.schema = 1
| .index_serial = $serial
| .valid_until = $vu
| .generated_at = $now
| .publishers = (.publishers // {})
| .publishers[$pub] = ((.publishers[$pub] // {display: $pub, role: $role})
+ (if $pubkey != "" then {key: $pubkey} else {} end))
| .artifacts = (((.artifacts // []) | map(select(.id != $env.id))) + [$env])
' <<<"$cur" > "$INDEX"
# --- sign the index -----------------------------------------------------------
if [[ -n "${LP_MINISIGN_SECKEY:-}" ]]; then
minisign -Sm "$INDEX" -s "$LP_MINISIGN_SECKEY" -t "libreportal artifact index serial $serial ($CHANNEL)" >/dev/null
fi
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"))"