#!/usr/bin/env bash # Publish a built artifact to the SilverDESK App Catalogue. # # NOTE: the silverdesk copy (tools/catalogue/publish-to-catalogue.sh) is the operator # copy; this one is used by CI. Keep the two in step. # # Usage: # publish-to-catalogue.sh --slug --platform \ # --arch --format \ # --version --version-code --channel \ # [--notes-file f] [--signer-identity id] [--sparkle-signature sig] \ # [--dry-run] # # Environment: # CATALOGUE_PUBLISH_KEY publish key (required; never printed) # CATALOGUE_URL base URL (default https://desk.silverlined.uk) # # Exit codes: 0 = created/added (201) or promoted (200: same bytes re-published # to a higher channel), 3 = already published (409, safe to rerun), # 1 = any other failure (server message printed). # # The uploaded FileName is the local file's base name sanitised to the server's # safe-name rule (spaces become '-', other unsafe characters are dropped); the # local path itself is used as given. Release notes are limited to 500 chars. set -euo pipefail DEFAULT_URL="https://desk.silverlined.uk" usage() { sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//' } die() { echo "error: $*" >&2 exit 1 } # The server's IsSafeName: ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ and no "..". sanitize_name() { local n="$1" n="${n// /-}" n="$(printf '%s' "$n" | LC_ALL=C tr -cd 'A-Za-z0-9._-')" while [[ "$n" == *..* ]]; do n="${n//../.}"; done while [ -n "$n" ] && [[ ! "$n" =~ ^[A-Za-z0-9] ]]; do n="${n:1}"; done printf '%s' "${n:0:128}" } SLUG=""; PLATFORM=""; ARCH=""; FORMAT=""; VERSION=""; VERSION_CODE="" CHANNEL=""; NOTES_FILE=""; SIGNER=""; SPARKLE_SIG=""; DRY_RUN=0; FILE="" while [ $# -gt 0 ]; do case "$1" in --slug) SLUG="${2:-}"; shift 2 ;; --platform) PLATFORM="${2:-}"; shift 2 ;; --arch) ARCH="${2:-}"; shift 2 ;; --format) FORMAT="${2:-}"; shift 2 ;; --version) VERSION="${2:-}"; shift 2 ;; --version-code) VERSION_CODE="${2:-}"; shift 2 ;; --channel) CHANNEL="${2:-}"; shift 2 ;; --notes-file) NOTES_FILE="${2:-}"; shift 2 ;; --signer-identity) SIGNER="${2:-}"; shift 2 ;; --sparkle-signature) SPARKLE_SIG="${2:-}"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; --*) die "unknown option: $1" ;; *) FILE="$1"; shift ;; esac done [ -n "$SLUG" ] || die "--slug is required" [ -n "$ARCH" ] || die "--arch is required" [ -n "$VERSION" ] || die "--version is required" [ -n "$VERSION_CODE" ] || die "--version-code is required" [ -n "$FILE" ] || die "artifact file is required" [ -f "$FILE" ] || die "artifact not found: $FILE" NOTES="" if [ -n "$NOTES_FILE" ]; then [ -f "$NOTES_FILE" ] || die "notes file not found: $NOTES_FILE" NOTES="$(cat "$NOTES_FILE")" NOTES_LEN="$(printf '%s' "$NOTES" | LC_ALL=C.UTF-8 wc -m 2>/dev/null | tr -d ' ')" [ "${NOTES_LEN:-0}" -le 500 ] || die "release notes are $NOTES_LEN characters; the catalogue allows at most 500" fi # Map lowercase CLI values to the server's enum names. case "$PLATFORM" in android) PLATFORM_ENUM="Android" ;; macos) PLATFORM_ENUM="MacOS" ;; linux) PLATFORM_ENUM="Linux" ;; windows) PLATFORM_ENUM="Windows" ;; *) die "unknown --platform '$PLATFORM' (android|macos|linux|windows)" ;; esac case "$FORMAT" in apk) FORMAT_ENUM="Apk" ;; dmg) FORMAT_ENUM="Dmg" ;; zip) FORMAT_ENUM="Zip" ;; deb) FORMAT_ENUM="Deb" ;; appimage) FORMAT_ENUM="AppImage" ;; msi) FORMAT_ENUM="Msi" ;; exe) FORMAT_ENUM="Exe" ;; msix) FORMAT_ENUM="Msix" ;; pwa) FORMAT_ENUM="Pwa" ;; nupkg) FORMAT_ENUM="Nupkg" ;; *) die "unknown --format '$FORMAT' (apk|dmg|zip|deb|appimage|msi|exe|msix|pwa|nupkg)" ;; esac case "$CHANNEL" in alpha) CHANNEL_ENUM="Alpha" ;; beta) CHANNEL_ENUM="Beta" ;; rc) CHANNEL_ENUM="ReleaseCandidate" ;; stable) CHANNEL_ENUM="Stable" ;; *) die "unknown --channel '$CHANNEL' (alpha|beta|rc|stable)" ;; esac BASE_URL="${CATALOGUE_URL:-$DEFAULT_URL}" BASE_URL="${BASE_URL%/}" URL="$BASE_URL/api/catalogue/publish" FILE_NAME="$(sanitize_name "$(basename "$FILE")")" [ -n "$FILE_NAME" ] || die "cannot derive a safe upload file name from: $(basename "$FILE")" # Build the multipart field list as an array so values with spaces survive. # Text fields use --form-string so a leading @ or < is never read as a file. FIELDS=( --form-string "AppSlug=$SLUG" --form-string "Platform=$PLATFORM_ENUM" --form-string "Arch=$ARCH" --form-string "Format=$FORMAT_ENUM" --form-string "Version=$VERSION" --form-string "VersionCode=$VERSION_CODE" --form-string "Channel=$CHANNEL_ENUM" --form-string "FileName=$FILE_NAME" ) [ -n "$SIGNER" ] && FIELDS+=(--form-string "SignerIdentity=$SIGNER") [ -n "$SPARKLE_SIG" ] && FIELDS+=(--form-string "SparkleEdSignature=$SPARKLE_SIG") [ -n "$NOTES" ] && FIELDS+=(--form-string "ReleaseNotes=$NOTES") # The file part must be last (the server reads every field before streaming it). # The path is quoted for curl so ';' or ',' in it are not parsed as part options. FILE_ESC="${FILE//\\/\\\\}" FILE_ESC="${FILE_ESC//\"/\\\"}" FIELDS+=(-F "file=@\"$FILE_ESC\";filename=$FILE_NAME") if [ "$DRY_RUN" -eq 1 ]; then # Print the command with the key redacted; the key never appears in output. printf 'curl -sS -X POST' printf ' -H "%s"' "Authorization: Bearer ***" for arg in "${FIELDS[@]}"; do printf ' %q' "$arg"; done printf ' %q\n' "$URL" exit 0 fi [ -n "${CATALOGUE_PUBLISH_KEY:-}" ] || die "CATALOGUE_PUBLISH_KEY is not set" # Pass the bearer header through a curl config on stdin (-K -), so the key never # lands in argv or the shell trace. (Not process substitution: native Windows # curl under Git Bash cannot open /dev/fd paths.) BODY_FILE="$(mktemp)" trap 'rm -f "$BODY_FILE"' EXIT HTTP_CODE="$(printf 'header = "Authorization: Bearer %s"\n' "$CATALOGUE_PUBLISH_KEY" | curl -sS -o "$BODY_FILE" -w '%{http_code}' -X POST -K - \ "${FIELDS[@]}" "$URL")" || die "request to $URL failed" BODY="$(cat "$BODY_FILE")" MESSAGE="$(printf '%s' "$BODY" | sed -n 's/.*"message" *: *"\([^"]*\)".*/\1/p' | head -n 1)" [ -n "$MESSAGE" ] || MESSAGE="$BODY" case "$HTTP_CODE" in 201) echo "published $SLUG $VERSION ($CHANNEL_ENUM, $PLATFORM_ENUM/$ARCH)" exit 0 ;; 200) echo "promoted $SLUG $VERSION to $CHANNEL_ENUM ($PLATFORM_ENUM/$ARCH): identical file already published" exit 0 ;; 409) echo "already published: $SLUG $VERSION ($CHANNEL_ENUM, $PLATFORM_ENUM/$ARCH): $MESSAGE" exit 3 ;; *) echo "publish failed (HTTP $HTTP_CODE): $MESSAGE" >&2 exit 1 ;; esac