From 74e7fc4194f1385939485da37a489a8550941748 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Wed, 21 Oct 2026 15:24:44 +0100 Subject: [PATCH] feat: catalogue-publish action Co-Authored-By: Claude Sonnet 5.5 --- README.md | 20 +++ catalogue-publish/action.yml | 70 +++++++++ catalogue-publish/publish-to-catalogue.sh | 182 ++++++++++++++++++++++ catalogue-publish/run.sh | 18 +++ catalogue-publish/test.sh | 23 +++ catalogue-publish/version-code.sh | 11 ++ 6 files changed, 324 insertions(+) create mode 100644 catalogue-publish/action.yml create mode 100644 catalogue-publish/publish-to-catalogue.sh create mode 100644 catalogue-publish/run.sh create mode 100644 catalogue-publish/test.sh create mode 100644 catalogue-publish/version-code.sh diff --git a/README.md b/README.md index a6aed31..183430c 100644 --- a/README.md +++ b/README.md @@ -38,3 +38,23 @@ Safety: Promotion to `stable` is manual: each app repo has a `Promote to F-Droid stable` workflow (dispatch with the tested tag). + +## catalogue-publish + +Publishes a built artifact to the SilverDESK App Catalogue (`desk.silverlined.uk/api/catalogue/publish`). Works on Linux and Windows runners (Git Bash, `shell: bash`). "Already published" (HTTP 409) is treated as success. + +```yaml +- uses: https://git.silverlabs.uk/SilverLABS/ci-actions/catalogue-publish@v1 + with: + app: silvermail + file: dist/SilverMAIL-0.1.5-x64.nupkg + platform: windows # android | macos | linux | windows + arch: x64 + format: nupkg # apk|dmg|zip|deb|appimage|msi|exe|msix|pwa|nupkg + version: 0.1.5 + channel: beta # alpha | beta | rc | stable + publish-key: ${{ secrets.CATALOGUE_PUBLISH_KEY }} + # optional: version-code, notes-file (<=500 chars), signer-identity, catalogue-url, dry-run +``` + +`version-code` defaults to `MAJOR*1000000 + MINOR*1000 + PATCH` (strict `x.y.z`, each below 1000; anything else fails the step, so pass `version-code` explicitly, e.g. `${{ github.run_number }}`). The key is passed through the environment only and never printed. `publish-to-catalogue.sh` is a copy of silverdesk's `tools/catalogue/publish-to-catalogue.sh`; keep them in step. Run `bash catalogue-publish/test.sh` for the offline tests. diff --git a/catalogue-publish/action.yml b/catalogue-publish/action.yml new file mode 100644 index 0000000..708b8ec --- /dev/null +++ b/catalogue-publish/action.yml @@ -0,0 +1,70 @@ +name: catalogue-publish +description: > + Publish a built artifact to the SilverDESK App Catalogue + (desk.silverlined.uk/api/catalogue/publish). A 409 (already published) is + treated as success. The publish key is passed through the environment only. +inputs: + app: + description: App slug (e.g. silvermail) + required: true + file: + description: Path to the artifact + required: true + platform: + description: android | macos | linux | windows + required: true + arch: + description: Architecture (x64, arm64, ...) + required: true + format: + description: apk | dmg | zip | deb | appimage | msi | exe | msix | pwa | nupkg + required: true + version: + description: Version string + required: true + channel: + description: alpha | beta | rc | stable + required: true + publish-key: + description: Catalogue publish key (secrets.CATALOGUE_PUBLISH_KEY) + required: true + version-code: + description: Version code (derived from strict semver MAJOR.MINOR.PATCH when empty) + required: false + default: '' + notes-file: + description: Release notes file (max 500 chars) + required: false + default: '' + signer-identity: + description: Signer identity, if the artifact is signed + required: false + default: '' + catalogue-url: + description: Catalogue base URL + required: false + default: https://desk.silverlined.uk + dry-run: + description: "'true' to print the redacted request and stop" + required: false + default: 'false' +runs: + using: composite + steps: + - name: Publish to catalogue (${{ inputs.app }} ${{ inputs.version }}) + shell: bash + env: + INPUT_APP: ${{ inputs.app }} + INPUT_FILE: ${{ inputs.file }} + INPUT_PLATFORM: ${{ inputs.platform }} + INPUT_ARCH: ${{ inputs.arch }} + INPUT_FORMAT: ${{ inputs.format }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_VERSION_CODE: ${{ inputs.version-code }} + INPUT_CHANNEL: ${{ inputs.channel }} + INPUT_NOTES_FILE: ${{ inputs.notes-file }} + INPUT_SIGNER_IDENTITY: ${{ inputs.signer-identity }} + INPUT_CATALOGUE_URL: ${{ inputs.catalogue-url }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + CATALOGUE_PUBLISH_KEY: ${{ inputs.publish-key }} + run: bash "${{ github.action_path }}/run.sh" diff --git a/catalogue-publish/publish-to-catalogue.sh b/catalogue-publish/publish-to-catalogue.sh new file mode 100644 index 0000000..6de8ef5 --- /dev/null +++ b/catalogue-publish/publish-to-catalogue.sh @@ -0,0 +1,182 @@ +#!/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 read from a process +# substitution, so the key never lands in argv or the shell trace. +BODY_FILE="$(mktemp)" +trap 'rm -f "$BODY_FILE"' EXIT +HTTP_CODE="$(curl -sS -o "$BODY_FILE" -w '%{http_code}' -X POST \ + -K <(printf 'header = "Authorization: Bearer %s"\n' "$CATALOGUE_PUBLISH_KEY") \ + "${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 diff --git a/catalogue-publish/run.sh b/catalogue-publish/run.sh new file mode 100644 index 0000000..49630ab --- /dev/null +++ b/catalogue-publish/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Run step for the catalogue-publish action; reads INPUT_* env set by action.yml. +set -euo pipefail +DIR="${GITHUB_ACTION_PATH:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +CODE="${INPUT_VERSION_CODE:-}" +[ -n "$CODE" ] || CODE="$(bash "$DIR/version-code.sh" "$INPUT_VERSION")" +ARGS=(--slug "$INPUT_APP" --platform "$INPUT_PLATFORM" --arch "$INPUT_ARCH" --format "$INPUT_FORMAT" + --version "$INPUT_VERSION" --version-code "$CODE" --channel "$INPUT_CHANNEL") +[ -z "${INPUT_NOTES_FILE:-}" ] || ARGS+=(--notes-file "$INPUT_NOTES_FILE") +[ -z "${INPUT_SIGNER_IDENTITY:-}" ] || ARGS+=(--signer-identity "$INPUT_SIGNER_IDENTITY") +[ "${INPUT_DRY_RUN:-false}" != "true" ] || ARGS+=(--dry-run) +export CATALOGUE_URL="${INPUT_CATALOGUE_URL:-https://desk.silverlined.uk}" +set +e +bash "$DIR/publish-to-catalogue.sh" "${ARGS[@]}" "$INPUT_FILE" +RC=$? +set -e +if [ $RC -eq 3 ]; then echo "::notice::already published"; exit 0; fi +exit $RC diff --git a/catalogue-publish/test.sh b/catalogue-publish/test.sh new file mode 100644 index 0000000..b8da514 --- /dev/null +++ b/catalogue-publish/test.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Offline tests for catalogue-publish (version-code.sh + dry-run via run.sh). +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FAILS=0 +ok(){ echo "PASS: $1"; }; bad(){ echo "FAIL: $1"; FAILS=$((FAILS+1)); } +vc_eq(){ local o; o="$(bash "$HERE/version-code.sh" "$1" 2>/dev/null)"; [ "$o" = "$2" ] && ok "$1 -> $2" || bad "$1 -> '$o' (want $2)"; } +vc_fail(){ bash "$HERE/version-code.sh" "$1" >/dev/null 2>&1 && bad "$1 should fail" || ok "$1 rejected"; } +vc_eq 0.1.5 1005; vc_eq 1.6.0 1006000; vc_eq 12.0.999 12000999 +vc_fail 1.2.1000; vc_fail 1.2; vc_fail v1.2.3; vc_fail 1.2.3-beta.1 + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +printf 'x' > "$TMP/App-1.0.0.nupkg" +SECRET="s3cr3t-PUBLISH-KEY-do-not-leak" +OUT="$(GITHUB_ACTION_PATH="$HERE" INPUT_APP=silvermail INPUT_FILE="$TMP/App-1.0.0.nupkg" \ + INPUT_PLATFORM=windows INPUT_ARCH=x64 INPUT_FORMAT=nupkg INPUT_VERSION=1.0.0 INPUT_VERSION_CODE= \ + INPUT_CHANNEL=beta INPUT_NOTES_FILE= INPUT_SIGNER_IDENTITY= INPUT_CATALOGUE_URL= INPUT_DRY_RUN=true \ + CATALOGUE_PUBLISH_KEY="$SECRET" bash "$HERE/run.sh" 2>&1)"; RC=$? +[ $RC -eq 0 ] && ok "dry-run exit 0" || bad "dry-run rc=$RC: $OUT" +for n in Platform=Windows Format=Nupkg VersionCode=1000000 AppSlug=silvermail; do + printf '%s' "$OUT" | grep -qF -- "$n" && ok "dry-run has $n" || bad "dry-run missing $n"; done +printf '%s' "$OUT" | grep -qF -- "$SECRET" && bad "key leaked" || ok "key not printed" +exit $FAILS diff --git a/catalogue-publish/version-code.sh b/catalogue-publish/version-code.sh new file mode 100644 index 0000000..ac00714 --- /dev/null +++ b/catalogue-publish/version-code.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Derive a version code from strict semver MAJOR.MINOR.PATCH (each 0-999): +# MAJOR*1000000 + MINOR*1000 + PATCH. Anything else exits 1. +set -eu +v="${1:-}" +if [[ "$v" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then + echo $(( 10#${BASH_REMATCH[1]} * 1000000 + 10#${BASH_REMATCH[2]} * 1000 + 10#${BASH_REMATCH[3]} )) +else + echo "error: version '$v' is not MAJOR.MINOR.PATCH with components < 1000; pass version-code explicitly" >&2 + exit 1 +fi