From c4c4f217bf178e3e23cd318d7d23912d3f4ba8e9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 14:12:10 +0200 Subject: [PATCH] =?UTF-8?q?ci(update):=20build+sign+publish=20the=20update?= =?UTF-8?q?=20manifest=20=E2=80=94=20stable=20at=20announce,=20canary=20af?= =?UTF-8?q?ter=20the=20canary=20installer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash+openssl signer (raw-64-byte ed25519 over exact bytes, base64 .sig — the plugin-index format) with the pinned-key cross-check, manifest-then-sig upload order, and a live-feed self-verify. announce.yml re-hashes the installer against its sidecar and fail-closes without UPDATE_MANIFEST_KEY; pre-release tags never enter the stable feed. windows-host.yml grows a Linux canary-manifest job. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/announce.yml | 31 ++++++ .gitea/workflows/windows-host.yml | 30 ++++++ scripts/ci/publish-update-manifest.sh | 132 ++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 scripts/ci/publish-update-manifest.sh diff --git a/.gitea/workflows/announce.yml b/.gitea/workflows/announce.yml index b4500f81..e4a66c3b 100644 --- a/.gitea/workflows/announce.yml +++ b/.gitea/workflows/announce.yml @@ -31,6 +31,37 @@ jobs: steps: - uses: actions/checkout@v4 + # Publish the SIGNED stable update manifest — the moment every host's update check learns + # about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in + # announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the + # fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed) + # or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are + # ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease. + - name: Publish the stable update manifest + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }} + run: | + set -euo pipefail + TAG="${{ inputs.tag }}" + case "$TAG" in + *-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;; + esac + VER="${TAG#v}" + URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe" + # Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth. + curl -fsSL "$URL" -o /tmp/installer.exe + curl -fsSL "$URL.sha256" -o /tmp/installer.sha256 + SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')" + grep -qi "$SHA" /tmp/installer.sha256 || { + echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2 + exit 1 + } + CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \ + WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \ + NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \ + bash scripts/ci/publish-update-manifest.sh + - name: Post release announcement to Discord env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 59364de8..68837e9b 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -460,6 +460,36 @@ jobs: # A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do # not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data # reads the manifests from the release, so it must not run before they are attached. + # Publish the SIGNED canary update manifest after the canary installer lands (planning: + # host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is + # the only artifact the manifest references by URL; other canary channels may trail by minutes, + # which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with + # a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it. + canary-manifest: + needs: package + if: gitea.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Publish the canary update manifest + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }} + run: | + set -euo pipefail + # Same derivation the package job used: canary = 's major.minor + run#. + eval "$(bash scripts/ci/pf-version.sh)" + VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}" + URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe" + curl -fsSL "$URL" -o /tmp/installer.exe + SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')" + CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \ + WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \ + NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \ + bash scripts/ci/publish-update-manifest.sh + winget-source: needs: package if: startsWith(gitea.ref, 'refs/tags/v') diff --git a/scripts/ci/publish-update-manifest.sh b/scripts/ci/publish-update-manifest.sh new file mode 100644 index 00000000..7182dfae --- /dev/null +++ b/scripts/ci/publish-update-manifest.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Build, sign, upload, and self-verify the host UPDATE MANIFEST for one channel +# (planning: host-update-from-web-console.md §3 — the check truth the console trusts). +# +# https://git.unom.io/api/packages/unom/generic/punktfunk-update//manifest.json +# https://git.unom.io/api/packages/unom/generic/punktfunk-update//manifest.json.sig +# +# The signature is a raw 64-byte Ed25519 over the EXACT manifest bytes, base64 in the .sig — +# the same format the plugin index uses and `store::index::verify_signature` checks. The +# public half is pinned in the host binary (UPDATE_KEYS in crates/punktfunk-host/src/update.rs); +# before signing, this script cross-checks the signing key against that constant and refuses +# on mismatch — the most likely deploy mistake is signing with a key no host trusts (the +# sysext publisher's fingerprint-crosscheck drill). +# +# Upload order is manifest THEN signature: a client caught in the replace window sees a +# mismatched pair and refuses (fail-closed), never a stale-signed document. +# +# Environment: +# CHANNEL stable | canary (required) +# VERSION the announced host version string (required) +# CI_RUN CI run number (required for canary) +# WINDOWS_URL immutable per-version installer URL (required for stable) +# WINDOWS_SHA256 hex sha256 of that installer (paired with WINDOWS_URL) +# AUTHENTICODE_SHA256 comma-separated accepted signing-leaf sha256s (optional) +# NOTES_URL release-notes link (git.unom.io only) (optional) +# UPDATE_MANIFEST_KEY PKCS#8 PEM, the Ed25519 private key (required to sign) +# REQUIRE_KEY=1 missing key is a hard failure (announce/stable) (optional) +# REGISTRY_TOKEN Gitea PAT with write:package (required) +# REGISTRY / OWNER default git.unom.io / unom +set -euo pipefail + +CHANNEL="${CHANNEL:?set CHANNEL=stable|canary}" +VERSION="${VERSION:?set VERSION}" +REGISTRY="${REGISTRY:-git.unom.io}" +OWNER="${OWNER:-unom}" +BASE="https://${REGISTRY}/api/packages/${OWNER}/generic/punktfunk-update/${CHANNEL}" + +case "$CHANNEL" in stable|canary) ;; *) echo "CHANNEL must be stable or canary" >&2; exit 1 ;; esac +if [ "$CHANNEL" = canary ] && [ -z "${CI_RUN:-}" ]; then + echo "canary manifests need CI_RUN (the definitive newer-than axis)" >&2; exit 1 +fi +if [ "$CHANNEL" = stable ] && [ -z "${WINDOWS_URL:-}" ]; then + echo "stable manifests need WINDOWS_URL/WINDOWS_SHA256 (the U1 apply leg)" >&2; exit 1 +fi +if [ -n "${WINDOWS_URL:-}" ] && ! printf '%s' "${WINDOWS_SHA256:-}" | grep -Eq '^[0-9a-f]{64}$'; then + echo "WINDOWS_SHA256 must be 64 hex chars when WINDOWS_URL is set" >&2; exit 1 +fi + +# ---- key handling (fail-closed where it matters) -------------------------------------------- +if [ -z "${UPDATE_MANIFEST_KEY:-}" ]; then + if [ "${REQUIRE_KEY:-0}" = 1 ] || case "${GITHUB_REF:-}" in refs/tags/v*) true ;; *) false ;; esac; then + echo "ERROR: UPDATE_MANIFEST_KEY is not set — refusing to publish an unsigned manifest" >&2 + exit 1 + fi + echo "WARN: UPDATE_MANIFEST_KEY not set — skipping the ${CHANNEL} update manifest" >&2 + exit 0 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +KEY="$WORK/key.pem" +# Tolerate secret stores that mangle newlines into literal \n (the plugin-index signer's fix). +if printf '%s' "$UPDATE_MANIFEST_KEY" | grep -q '\\n' && ! printf '%s' "$UPDATE_MANIFEST_KEY" | grep -q '^-----BEGIN.*-----$'; then + printf '%s' "$UPDATE_MANIFEST_KEY" | sed 's/\\n/\n/g' > "$KEY" +else + printf '%s\n' "$UPDATE_MANIFEST_KEY" > "$KEY" +fi + +# Cross-check: the key we are about to sign with must be one the host binary pins. +PUB="ed25519:$(openssl pkey -in "$KEY" -pubout -outform DER | tail -c 32 | base64)" +KEYS_FILE="crates/punktfunk-host/src/update.rs" +if [ -f "$KEYS_FILE" ]; then + if ! grep -qF "\"$PUB\"" "$KEYS_FILE"; then + echo "ERROR: signing key $PUB is not pinned in $KEYS_FILE (UPDATE_KEYS) — wrong key?" >&2 + exit 1 + fi +else + echo "WARN: $KEYS_FILE not in this checkout — skipping the pinned-key cross-check" >&2 +fi + +# ---- build the manifest --------------------------------------------------------------------- +SERIAL="$(date +%s)" +PUBLISHED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +MANIFEST="$WORK/manifest.json" + +AUTH_JSON="$(printf '%s' "${AUTHENTICODE_SHA256:-}" | jq -R 'split(",") | map(select(length > 0))')" +jq -n \ + --arg channel "$CHANNEL" \ + --arg version "$VERSION" \ + --arg published_at "$PUBLISHED_AT" \ + --arg notes_url "${NOTES_URL:-}" \ + --argjson serial "$SERIAL" \ + --arg win_url "${WINDOWS_URL:-}" \ + --arg win_sha "${WINDOWS_SHA256:-}" \ + --argjson auth "$AUTH_JSON" \ + --arg ci_run "${CI_RUN:-}" \ + ' + {schema: 1, channel: $channel, serial: $serial, published_at: $published_at, version: $version} + + (if $notes_url != "" then {notes_url: $notes_url} else {} end) + + (if $ci_run != "" then {ci_run: ($ci_run | tonumber)} else {} end) + + (if $win_url != "" then {windows_host: {url: $win_url, sha256: $win_sha, authenticode_sha256: $auth}} else {} end) + ' > "$MANIFEST" +echo "manifest:"; cat "$MANIFEST" + +# ---- sign + local verify -------------------------------------------------------------------- +SIG_BIN="$WORK/sig.bin" +openssl pkeyutl -sign -inkey "$KEY" -rawin -in "$MANIFEST" -out "$SIG_BIN" +[ "$(wc -c < "$SIG_BIN")" -eq 64 ] || { echo "ERROR: signature is not 64 bytes" >&2; exit 1; } +SIG="$WORK/manifest.json.sig" +base64 < "$SIG_BIN" | tr -d '\n' > "$SIG"; printf '\n' >> "$SIG" + +PUBPEM="$WORK/pub.pem" +openssl pkey -in "$KEY" -pubout -out "$PUBPEM" +openssl pkeyutl -verify -pubin -inkey "$PUBPEM" -rawin -in "$MANIFEST" -sigfile "$SIG_BIN" >/dev/null + +# ---- upload (manifest first, then signature) ------------------------------------------------ +: "${REGISTRY_TOKEN:?set REGISTRY_TOKEN}" +upload() { # file url + curl -fsS -o /dev/null --user "enricobuehler:${REGISTRY_TOKEN}" -X DELETE "$2" 2>/dev/null || true + curl -fsS -o /dev/null --user "enricobuehler:${REGISTRY_TOKEN}" --upload-file "$1" "$2" + echo "published: $2" +} +upload "$MANIFEST" "$BASE/manifest.json" +upload "$SIG" "$BASE/manifest.json.sig" + +# ---- self-verify the LIVE feed (bytes must round-trip; -L follows the 303) ------------------ +curl -fsSL "$BASE/manifest.json" -o "$WORK/live.json" +curl -fsSL "$BASE/manifest.json.sig" -o "$WORK/live.sig" +cmp -s "$MANIFEST" "$WORK/live.json" || { echo "ERROR: live manifest differs from what was uploaded" >&2; exit 1; } +cmp -s "$SIG" "$WORK/live.sig" || { echo "ERROR: live signature differs from what was uploaded" >&2; exit 1; } +openssl pkeyutl -verify -pubin -inkey "$PUBPEM" -rawin -in "$WORK/live.json" -sigfile "$SIG_BIN" >/dev/null +echo "OK: ${CHANNEL} update manifest ${VERSION} (serial ${SERIAL}) is live and verifies"