The maintainer-side tool that turns a small hotfix SPEC into the two signed
artifacts the install verifies + applies (completes the hotfix product):
dist/<channel>/payloads/<id>.json(.minisig) the bounded declarative op list
dist/<channel>/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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
149 lines
7.1 KiB
Bash
149 lines
7.1 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 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": "<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)"
|
|
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="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"
|