From e601ec84344ab51ded1b23979ef3de29c98ee38c Mon Sep 17 00:00:00 2001 From: librelad Date: Sun, 31 May 2026 21:22:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20Phase=205=20=E2=80=94=20m?= =?UTF-8?q?ake=5Fhotfix.sh=20publisher=20tooling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer-side tool that turns a small hotfix SPEC into the two signed artifacts the install verifies + applies (completes the hotfix product): dist//payloads/.json(.minisig) the bounded declarative op list dist//index.json(.minisig) the catalog (entry upserted, serial++) laid out exactly like get.libreportal.org serves it (local-serve testable). - Reads a spec (envelope fields + an embedded ops array); inlines any op `content_file` to content_b64 for convenience. - Validates id charset + every op name against the applier's CLOSED vocabulary, so a typo can't ship an artifact that fails-closed on every box. - Builds the payload (sha256), the envelope (payload ref {kind,url,sha256,sig}), and upserts it into index.json — bumping index_serial, refreshing valid_until (LP_HOTFIX_VALID_DAYS, default 30), and recording the publisher in the publishers map with role + the footprint public key. - minisign-signs the payload + index when LP_MINISIGN_SECKEY is set (the offline key, kept on the release machine, same as make_release.sh); unsigned otherwise for local testing — `libreportal artifact apply` refuses to apply unsigned. Verified end-to-end (unsigned mode): produces a valid index.json + payload.json matching the §8.1 envelope that lpFetchIndex / artifactApply consume. Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- scripts/release/make_hotfix.sh | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 scripts/release/make_hotfix.sh diff --git a/scripts/release/make_hotfix.sh b/scripts/release/make_hotfix.sh new file mode 100644 index 0000000..133c09e --- /dev/null +++ b/scripts/release/make_hotfix.sh @@ -0,0 +1,148 @@ +#!/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//payloads/.json(.minisig) the bounded declarative op list +# dist//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 [channel] +# 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). +# +# 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": "" 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)" +SPEC="${1:-}" +CHANNEL="${2:-stable}" +[[ -n "$SPEC" && -f "$SPEC" ]] || { echo "make_hotfix: usage: make_hotfix.sh [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&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="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 + +# --- 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 +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, bump serial, refresh freshness -------------------- +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 + +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"