diff --git a/scripts/release/lib/release_index.sh b/scripts/release/lib/release_index.sh new file mode 100644 index 0000000..db504c2 --- /dev/null +++ b/scripts/release/lib/release_index.sh @@ -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 — 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 — 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 +# 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" +} diff --git a/scripts/release/make_app.sh b/scripts/release/make_app.sh new file mode 100644 index 0000000..0d86194 --- /dev/null +++ b/scripts/release/make_app.sh @@ -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// — 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" diff --git a/scripts/release/make_hotfix.sh b/scripts/release/make_hotfix.sh index 133c09e..0985623 100644 --- a/scripts/release/make_hotfix.sh +++ b/scripts/release/make_hotfix.sh @@ -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 [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"))"