diff --git a/.github/scripts/keys/generate-signing-key.sh b/.github/scripts/keys/generate-signing-key.sh new file mode 100755 index 0000000..4d70879 --- /dev/null +++ b/.github/scripts/keys/generate-signing-key.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -e + +# Generates a GPG Ed25519 signing key for Dispatcharr manifest signing. +# Outputs: +# dispatcharr-plugins.pub - public key to bundle in the Dispatcharr app +# dispatcharr-plugins.key - private key to set as the GPG_PRIVATE_KEY repo secret +# dispatcharr-plugins.pass - passphrase to set as the GPG_PASSPHRASE repo secret + +EMAIL="plugins@dispatcharr.tv" +NAME="Dispatcharr Plugin Repo" +PASSPHRASE=$(LC_ALL=C tr -dc 'A-Za-z0-9' " +echo " Algorithm: Ed25519" +echo " Passphrase: $PASSPHRASE" +echo "" + +gpg --batch --pinentry-mode loopback --passphrase "$PASSPHRASE" --gen-key </dev/null | awk -F: '/^fpr/{print $10}' | head -1) + +rm -f "$KEYS_DIR/dispatcharr-plugins.key" "$KEYS_DIR/dispatcharr-plugins.pub" "$KEYS_DIR/dispatcharr-plugins.pass" +gpg --batch --pinentry-mode loopback --passphrase "$PASSPHRASE" --armor --export-secret-keys "$FPR" > "$KEYS_DIR/dispatcharr-plugins.key" +gpg --armor --export "$FPR" > "$KEYS_DIR/dispatcharr-plugins.pub" +echo "$PASSPHRASE" > "$KEYS_DIR/dispatcharr-plugins.pass" + +echo "" +gpg --list-secret-keys --keyid-format LONG + +echo "" +echo "Files written:" +echo " dispatcharr-plugins.pub → bundle into Dispatcharr app (included in this repo)" +echo " dispatcharr-plugins.key → set as GPG_PRIVATE_KEY repo secret (ignored by .gitignore)" +echo " dispatcharr-plugins.pass → set as GPG_PASSPHRASE repo secret (ignored by .gitignore)" +echo "" diff --git a/.github/scripts/publish/build-zips.sh b/.github/scripts/publish/build-zips.sh index 8a8e598..28d7de2 100644 --- a/.github/scripts/publish/build-zips.sh +++ b/.github/scripts/publish/build-zips.sh @@ -2,14 +2,18 @@ set -e # publish-build-zips.sh -# Builds versioned ZIPs and per-version metadata files for all plugins. -# Skips plugins whose current version already has a ZIP + metadata file. +# Builds versioned ZIPs and per-version metadata for all plugins. +# Per-version metadata is written to a temporary working directory (BUILD_META_DIR) +# so generate-manifest.sh can consume it within this CI run without persisting +# per-version JSON files to the releases branch. +# Skips plugins whose current version already has a ZIP and an entry in the +# existing per-plugin manifest. # Writes changed_plugins.txt to cwd (one "name@version" per line). # # Called from the releases branch checkout directory by publish-plugins.sh. # Required env: SOURCE_BRANCH, RELEASES_BRANCH, GITHUB_REPOSITORY -: "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" +: "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" "${BUILD_META_DIR:?}" > changed_plugins.txt @@ -18,14 +22,18 @@ for plugin_dir in plugins/*/; do plugin_name=$(basename "$plugin_dir") version=$(jq -r '.version' "$plugin_dir/plugin.json") - mkdir -p "releases/$plugin_name" "metadata/$plugin_name" + mkdir -p "zips/$plugin_name" - zip_path="releases/$plugin_name/${plugin_name}-${version}.zip" - metadata_path="metadata/$plugin_name/${plugin_name}-${version}.json" + zip_path="zips/$plugin_name/${plugin_name}-${version}.zip" + existing_manifest="zips/$plugin_name/manifest.json" - if [[ -f "$zip_path" ]] && [[ -f "$metadata_path" ]]; then - echo " $plugin_name v$version - skipping (already exists)" - continue + # Skip if ZIP exists and the version is already in the existing manifest + if [[ -f "$zip_path" ]]; then + if [[ -f "$existing_manifest" ]] && \ + jq -e --arg v "$version" '.manifest.versions[]? | select(.version == $v)' "$existing_manifest" >/dev/null 2>&1; then + echo " $plugin_name v$version - skipping (already exists)" + continue + fi fi echo " $plugin_name v$version - building" @@ -45,6 +53,7 @@ for plugin_dir in plugins/*/; do min_da_version=$(jq -r '.min_dispatcharr_version // ""' "$plugin_dir/plugin.json") max_da_version=$(jq -r '.max_dispatcharr_version // ""' "$plugin_dir/plugin.json") + mkdir -p "$BUILD_META_DIR/$plugin_name" jq -n \ --arg version "$version" \ --arg commit_sha "$commit_sha" \ @@ -65,9 +74,9 @@ for plugin_dir in plugins/*/; do checksum_sha256: $checksum_sha256 } + (if $min_da_version != "" then {min_dispatcharr_version: $min_da_version} else {} end) + (if $max_da_version != "" then {max_dispatcharr_version: $max_da_version} else {} end)' \ - > "$metadata_path" + > "$BUILD_META_DIR/$plugin_name/${plugin_name}-${version}.json" - cp "$zip_path" "releases/$plugin_name/${plugin_name}-latest.zip" + cp "$zip_path" "zips/$plugin_name/${plugin_name}-latest.zip" done changed=$(wc -l < changed_plugins.txt | tr -d ' ') diff --git a/.github/scripts/publish/cleanup.sh b/.github/scripts/publish/cleanup.sh index dec22cb..3010e8f 100644 --- a/.github/scripts/publish/cleanup.sh +++ b/.github/scripts/publish/cleanup.sh @@ -3,7 +3,7 @@ set -e # publish-cleanup.sh # Removes release artifacts for plugins that no longer exist in source, -# prunes versioned ZIPs beyond MAX_VERSIONED_ZIPS, and removes orphaned files. +# and prunes versioned ZIPs beyond MAX_VERSIONED_ZIPS. # # Called from the releases branch checkout directory by publish-plugins.sh. # Required env: SOURCE_BRANCH @@ -13,52 +13,28 @@ set -e MAX_VERSIONED_ZIPS=${MAX_VERSIONED_ZIPS:-10} # Remove artifacts for deleted plugins -if [[ -d releases ]]; then - for release_dir in releases/*/; do +if [[ -d zips ]]; then + for release_dir in zips/*/; do [[ ! -d "$release_dir" ]] && continue plugin_name=$(basename "$release_dir") if [[ ! -d "plugins/$plugin_name" ]]; then echo " Removing deleted plugin: $plugin_name" - rm -rf "$release_dir" "metadata/$plugin_name" + rm -rf "$release_dir" fi done fi -# Prune old versions and orphans per plugin +# Prune old versions per plugin for plugin_dir in plugins/*/; do [[ ! -d "$plugin_dir" ]] && continue plugin_name=$(basename "$plugin_dir") - zip_dir="releases/$plugin_name" - metadata_dir="metadata/$plugin_name" + zip_dir="zips/$plugin_name" # Remove oldest ZIPs beyond the limit while IFS= read -r old_zip; do - version=$(basename "$old_zip" | sed "s/${plugin_name}-\(.*\)\.zip/\1/") - rm -f "$old_zip" "$metadata_dir/${plugin_name}-${version}.json" - echo " Removed $plugin_name v$version (over limit)" + echo " Removed $plugin_name $(basename "$old_zip") (over limit)" + rm -f "$old_zip" done < <(ls -1t "$zip_dir/${plugin_name}-"*.zip 2>/dev/null \ | grep -v "${plugin_name}-latest.zip" \ | awk "NR>$MAX_VERSIONED_ZIPS") - - # Remove orphaned ZIPs with no matching metadata - for zipfile in "$zip_dir/${plugin_name}-"*.zip; do - [[ ! -f "$zipfile" ]] && continue - zip_basename=$(basename "$zipfile") - [[ "$zip_basename" == "${plugin_name}-latest.zip" ]] && continue - version=$(echo "$zip_basename" | sed "s/${plugin_name}-\(.*\)\.zip/\1/") - if [[ ! -f "$metadata_dir/${plugin_name}-${version}.json" ]]; then - rm -f "$zipfile" - echo " Removed orphaned ZIP: $zip_basename" - fi - done - - # Remove orphaned metadata with no matching ZIP - for metafile in "$metadata_dir/${plugin_name}-"*.json; do - [[ ! -f "$metafile" ]] && continue - version=$(basename "$metafile" | sed "s/${plugin_name}-\(.*\)\.json/\1/") - if [[ ! -f "$zip_dir/${plugin_name}-${version}.zip" ]]; then - rm -f "$metafile" - echo " Removed orphaned metadata: $(basename "$metafile")" - fi - done done diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index a2838d7..db06dc5 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -2,13 +2,107 @@ set -e # publish-generate-manifest.sh -# Generates metadata//manifest.json for each plugin and the root manifest.json. +# Generates zips//manifest.json for each plugin and the root manifest.json. # # Called from the releases branch checkout directory by publish-plugins.sh. # Required env: SOURCE_BRANCH, RELEASES_BRANCH, GITHUB_REPOSITORY : "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" +generated_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +repo_url="https://github.com/${GITHUB_REPOSITORY}" +repo_name="${GITHUB_REPOSITORY}" + +# GPG signing setup - optional; set GPG_PRIVATE_KEY (armored) and optionally GPG_PASSPHRASE +gpg_key_id="" +gpg_signing_failed=0 +if [[ -n "${GPG_PRIVATE_KEY:-}" ]]; then + echo "$GPG_PRIVATE_KEY" | gpg --batch --import 2>/dev/null + gpg_key_id=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null \ + | awk '/^sec/{print $2}' | head -1 | cut -d'/' -f2) + if [[ -n "$gpg_key_id" ]]; then + echo "GPG signing enabled (key: $gpg_key_id)" + else + echo "::warning::GPG key import succeeded but no usable secret key found - signatures will be skipped." + gpg_signing_failed=1 + fi +else + echo "GPG_PRIVATE_KEY not set - signatures will be skipped." +fi + +# Writes a manifest wrapper to $1 with $2 as the signed payload (.manifest), +# only when .manifest content differs from what is on disk. +# Wrapper structure: {generated_at, repo_url, repo_name, manifest: } +# The .signature field is added separately by sign_manifest. +# Returns 0 if written, 1 if skipped (content unchanged). +write_manifest_if_changed() { + local dest="$1" manifest_payload="$2" + local new_compact + new_compact=$(echo "$manifest_payload" | jq -c '.') + if [[ -f "$dest" ]]; then + local existing_manifest + existing_manifest=$(jq -c '.manifest' "$dest" 2>/dev/null) + if [[ "$existing_manifest" == "$new_compact" ]]; then + return 1 + fi + fi + jq -n \ + --arg generated_at "$generated_at" \ + --arg repo_url "$repo_url" \ + --arg repo_name "$repo_name" \ + --argjson manifest "$new_compact" \ + '{generated_at: $generated_at, repo_url: $repo_url, repo_name: $repo_name, manifest: $manifest}' \ + > "$dest" + return 0 +} + +# Returns 0 if the manifest at $1 has an embedded .signature made by the current +# gpg_key_id, 1 otherwise. Uses --list-packets on a temp file to read the issuer +# key ID without cryptographic verification, avoiding trust-level pitfalls. +sig_is_current() { + local file="$1" + local sig + sig=$(jq -r '.signature // empty' "$file" 2>/dev/null) + [[ -n "$sig" ]] || return 1 + local tmp_sig sig_key_id + tmp_sig=$(mktemp) + printf '%s\n' "$sig" > "$tmp_sig" + sig_key_id=$(gpg --list-packets "$tmp_sig" 2>/dev/null \ + | sed -n 's/.*issuer key ID \([A-Fa-f0-9]\{16\}\).*/\1/p' | head -1 \ + | tr 'a-f' 'A-F') + rm -f "$tmp_sig" + local cur_key_upper + cur_key_upper=$(echo "$gpg_key_id" | tr 'a-f' 'A-F') + [[ -n "$sig_key_id" && "$sig_key_id" == "$cur_key_upper" ]] +} + +# Signs the .manifest payload of $1 and embeds the armored signature as .signature +# in the same JSON file. Sets gpg_signing_failed=1 on any error. +sign_manifest() { + local file="$1" + [[ -z "$gpg_key_id" ]] && return 0 + local gpg_opts=(--batch --yes --armor --detach-sign --local-user "$gpg_key_id" --output -) + if [[ -n "${GPG_PASSPHRASE:-}" ]]; then + gpg_opts+=(--passphrase "$GPG_PASSPHRASE" --pinentry-mode loopback) + fi + local sig + sig=$(jq -c '.manifest' "$file" | gpg "${gpg_opts[@]}" 2>/dev/null) || true + if [[ -z "$sig" ]]; then + echo "::warning::GPG signing failed for ${file} - all signatures will be removed." + gpg_signing_failed=1 + return 1 + fi + local tmp + tmp=$(mktemp) + if jq --arg sig "$sig" '. + {signature: $sig}' "$file" > "$tmp"; then + mv "$tmp" "$file" + else + rm -f "$tmp" + echo "::warning::Failed to embed signature in ${file}." + gpg_signing_failed=1 + fi +} + plugin_entries=() root_entries=() @@ -16,22 +110,35 @@ for plugin_dir in plugins/*/; do plugin_file="$plugin_dir/plugin.json" [[ ! -f "$plugin_file" ]] && continue plugin_name=$(basename "$plugin_dir") + [[ "$(jq -r '.unlisted // false' "$plugin_file")" == "true" ]] && continue echo " $plugin_name" - latest_url="https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${plugin_name}-latest.zip" + latest_url="https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${plugin_name}-latest.zip" versioned_zips="[]" latest_metadata="{}" + # existing per-plugin manifest from previous run - used as metadata fallback + existing_manifest_file="zips/$plugin_name/manifest.json" + while IFS= read -r zipfile; do zip_basename=$(basename "$zipfile") zip_version=$(echo "$zip_basename" | sed "s/${plugin_name}-\(.*\)\.zip/\1/") - zip_url="https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${zip_basename}" - metadata_file="metadata/$plugin_name/${plugin_name}-${zip_version}.json" + zip_url="https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${zip_basename}" + + # Fresh metadata from this run takes priority; fall back to existing manifest + fresh_meta_file="${BUILD_META_DIR:-}/$plugin_name/${plugin_name}-${zip_version}.json" + metadata="{}" + if [[ -n "${BUILD_META_DIR:-}" && -f "$fresh_meta_file" ]]; then + metadata=$(cat "$fresh_meta_file") + elif [[ -f "$existing_manifest_file" ]]; then + meta_from_manifest=$(jq -c --arg v "$zip_version" \ + '.manifest.versions[]? | select(.version == $v)' "$existing_manifest_file" 2>/dev/null || true) + [[ -n "$meta_from_manifest" ]] && metadata="$meta_from_manifest" + fi - if [[ -f "$metadata_file" ]]; then - metadata=$(cat "$metadata_file") + if [[ "$metadata" != "{}" ]]; then versioned_zips=$(jq --arg url "$zip_url" --argjson metadata "$metadata" \ '. + [($metadata + {url: $url})]' <<< "$versioned_zips") if [[ "$latest_metadata" == "{}" ]]; then @@ -41,7 +148,7 @@ for plugin_dir in plugins/*/; do versioned_zips=$(jq --arg version "$zip_version" --arg url "$zip_url" \ '. + [{version: $version, url: $url}]' <<< "$versioned_zips") fi - done < <(ls -1 "releases/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ + done < <(ls -1 "zips/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ | grep -v latest | sort -t- -k2 -V -r) # Compute icon_url before building plugin_entry so it can be included in both manifests @@ -57,11 +164,10 @@ for plugin_dir in plugins/*/; do --argjson versioned_zips "$versioned_zips" \ --argjson latest_metadata "$latest_metadata" \ 'with_entries(select(.key | IN( - "name","version","description","author","maintainers", - "deprecated","unlisted","min_dispatcharr_version","max_dispatcharr_version","repo_url","discord_thread","license" + "name","description","author","maintainers", + "deprecated","repo_url","discord_thread","license" ))) + { slug: $plugin_name, - latest_url: $latest_url, versions: $versioned_zips } + (if $icon_url != "" then {icon_url: $icon_url} else {} end) + ( @@ -70,17 +176,16 @@ for plugin_dir in plugins/*/; do latest: ($latest_metadata + { latest_url: $latest_url, url: $versioned_zips[0].url - }), - latest_commit_sha: $latest_metadata.commit_sha, - latest_commit_sha_short: $latest_metadata.commit_sha_short, - latest_build_timestamp: $latest_metadata.build_timestamp, - latest_checksum_md5: $latest_metadata.checksum_md5, - latest_checksum_sha256: $latest_metadata.checksum_sha256 + }) } else {} end )' \ "$plugin_file") - echo "$plugin_entry" | jq '.' > "metadata/$plugin_name/manifest.json" + if write_manifest_if_changed "zips/$plugin_name/manifest.json" "$plugin_entry"; then + sign_manifest "zips/$plugin_name/manifest.json" + elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "zips/$plugin_name/manifest.json"; then + sign_manifest "zips/$plugin_name/manifest.json" + fi plugin_entries+=("$plugin_entry") # Compact root manifest entry @@ -91,18 +196,20 @@ for plugin_dir in plugins/*/; do desc_trimmed="$desc_raw" fi - plugin_manifest_url="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${RELEASES_BRANCH}/metadata/${plugin_name}/manifest.json" + plugin_manifest_url="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${RELEASES_BRANCH}/zips/${plugin_name}/manifest.json" root_entry=$(jq -n \ --argjson latest_metadata "$latest_metadata" \ + --argjson versioned_zips "$versioned_zips" \ + --arg slug "$plugin_name" \ --arg name "$(jq -r '.name // ""' "$plugin_file")" \ --arg description "$desc_trimmed" \ --arg icon_url "$icon_url" \ --arg manifest_url "$plugin_manifest_url" \ --arg author "$(jq -r '.author // ""' "$plugin_file")" \ --arg license "$(jq -r '.license // ""' "$plugin_file")" \ - --arg latest_url "$latest_url" \ '{ + slug: $slug, name: $name, description: $description, icon_url: (if $icon_url != "" then $icon_url else null end), @@ -111,25 +218,48 @@ for plugin_dir in plugins/*/; do license: (if $license != "" then $license else null end), latest_version: ($latest_metadata.version // null), latest_md5: ($latest_metadata.checksum_md5 // null), - latest_url: $latest_url, + latest_sha256: ($latest_metadata.checksum_sha256 // null), + latest_url: ($versioned_zips[0].url // null), min_dispatcharr_version: ($latest_metadata.min_dispatcharr_version // null), max_dispatcharr_version: ($latest_metadata.max_dispatcharr_version // null) } | with_entries(select(.value != null))') root_entries+=("$root_entry") done -{ - echo '{' - echo ' "plugins": [' - first=true - for entry in "${root_entries[@]}"; do - if [[ "$first" != true ]]; then echo ","; fi - first=false - echo "$entry" | sed 's/^/ /' - done - echo "" - echo ' ]' - echo '}' -} | jq --arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" '{generated_at: $ts} + .' > manifest.json +inner_root=$( + { + echo '{' + echo ' "plugins": [' + first=true + for entry in "${root_entries[@]}"; do + if [[ "$first" != true ]]; then echo ","; fi + first=false + echo "$entry" | sed 's/^/ /' + done + echo "" + echo ' ]' + echo '}' + } | jq -c '.' +) +if write_manifest_if_changed "manifest.json" "$inner_root"; then + sign_manifest "manifest.json" +elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "manifest.json"; then + sign_manifest "manifest.json" +fi + +# If any signing step failed, or no GPG key is configured, strip embedded +# signatures from all manifests so the repo is never left in a partially-signed +# or stale-signed state (e.g. incremental runs where unchanged manifests retain +# signatures from a previous key that is no longer present). +if [[ "$gpg_signing_failed" -eq 1 ]] || [[ -z "$gpg_key_id" ]]; then + echo "::warning::Removing all manifest signatures (no GPG key configured or signing failed)." + while IFS= read -r -d '' _f; do + _tmp=$(mktemp) + jq 'del(.signature)' "$_f" > "$_tmp" && mv "$_tmp" "$_f" || rm -f "$_tmp" + done < <(find zips -name "manifest.json" -print0 2>/dev/null) + _tmp=$(mktemp) + jq 'del(.signature)' manifest.json > "$_tmp" && mv "$_tmp" manifest.json || rm -f "$_tmp" + unset _f _tmp +fi echo "Generated manifest.json with ${#root_entries[@]} plugin(s)." diff --git a/.github/scripts/publish/plugin-readmes.sh b/.github/scripts/publish/plugin-readmes.sh index dd801a3..f32ddc2 100644 --- a/.github/scripts/publish/plugin-readmes.sh +++ b/.github/scripts/publish/plugin-readmes.sh @@ -2,7 +2,7 @@ set -e # publish-per-plugin-readmes.sh -# Generates releases//README.md for every plugin. +# Generates zips//README.md for every plugin. # # Called from the releases branch checkout directory by publish-plugins.sh. # Required env: SOURCE_BRANCH, RELEASES_BRANCH, GITHUB_REPOSITORY @@ -53,36 +53,39 @@ for plugin_dir in plugins/*/; do echo "### Latest Release" echo "" - latest_zip="releases/$plugin_name/${plugin_name}-latest.zip" + latest_zip="zips/$plugin_name/${plugin_name}-latest.zip" if [[ -f "$latest_zip" ]]; then - latest_versioned=$(ls -1 "releases/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ + latest_versioned=$(ls -1 "zips/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ | grep -v latest | sort -t- -k2 -V -r | head -1) if [[ -n "$latest_versioned" ]]; then zip_basename=$(basename "$latest_versioned") latest_version=$(echo "$zip_basename" | sed "s/${plugin_name}-\(.*\)\.zip/\1/") - metadata_file="metadata/$plugin_name/${plugin_name}-${latest_version}.json" - - echo "**Version:** \`$latest_version\`" - echo "" - - if [[ -f "$metadata_file" ]]; then - commit_sha=$(jq -r '.commit_sha' "$metadata_file") - commit_sha_short=$(jq -r '.commit_sha_short' "$metadata_file") - build_timestamp=$(jq -r '.build_timestamp' "$metadata_file") - checksum_md5=$(jq -r '.checksum_md5' "$metadata_file") - checksum_sha256=$(jq -r '.checksum_sha256' "$metadata_file") - - echo "- **Download:** [\`${plugin_name}-latest.zip\`](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${plugin_name}-latest.zip)" - echo "- **Built:** $(fmt_date "$build_timestamp")" - echo "- **Source Commit:** [\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha})" - echo "" - echo "**Checksums:**" - echo "\`\`\`" - echo "MD5: $checksum_md5" - echo "SHA256: $checksum_sha256" - echo "\`\`\`" + manifest_file="zips/$plugin_name/manifest.json" + meta_entry="" + if [[ -f "$manifest_file" ]]; then + meta_entry=$(jq -c --arg v "$latest_version" \ + '.manifest.versions[]? | select(.version == $v)' "$manifest_file" 2>/dev/null || true) + fi + if [[ -n "$meta_entry" ]]; then + commit_sha=$(echo "$meta_entry" | jq -r '.commit_sha // empty') + commit_sha_short=$(echo "$meta_entry" | jq -r '.commit_sha_short // empty') + build_timestamp=$(echo "$meta_entry" | jq -r '.build_timestamp // empty') + checksum_md5=$(echo "$meta_entry" | jq -r '.checksum_md5 // empty') + checksum_sha256=$(echo "$meta_entry" | jq -r '.checksum_sha256 // empty') + + echo "- **Download:** [\`${plugin_name}-latest.zip\`](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${plugin_name}-latest.zip)" + [[ -n "$build_timestamp" ]] && echo "- **Built:** $(fmt_date "$build_timestamp")" + [[ -n "$commit_sha" ]] && echo "- **Source Commit:** [\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha})" + if [[ -n "$checksum_md5" || -n "$checksum_sha256" ]]; then + echo "" + echo "**Checksums:**" + echo "\`\`\`" + [[ -n "$checksum_md5" ]] && echo "MD5: $checksum_md5" + [[ -n "$checksum_sha256" ]] && echo "SHA256: $checksum_sha256" + echo "\`\`\`" + fi else - echo "- **Download:** [\`${plugin_name}-latest.zip\`](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${plugin_name}-latest.zip)" + echo "- **Download:** [\`${plugin_name}-latest.zip\`](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${plugin_name}-latest.zip)" fi fi fi @@ -90,25 +93,34 @@ for plugin_dir in plugins/*/; do echo "" echo "### All Versions" echo "" - echo "| Version | Download | Built | Commit | MD5 Checksum |" - echo "|---------|----------|-------|--------|--------------|" + echo "| Version | Download | Built | Commit | MD5 | SHA256 |" + echo "|---------|----------|-------|--------|-----|--------|" + manifest_file="zips/$plugin_name/manifest.json" while IFS= read -r zipfile; do zip_basename=$(basename "$zipfile") version=$(echo "$zip_basename" | sed "s/${plugin_name}-\(.*\)\.zip/\1/") - metadata_file="metadata/$plugin_name/${plugin_name}-${version}.json" - if [[ -f "$metadata_file" ]]; then - commit_sha_short=$(jq -r '.commit_sha_short' "$metadata_file") - commit_sha=$(jq -r '.commit_sha' "$metadata_file") - build_timestamp=$(jq -r '.build_timestamp' "$metadata_file") - checksum_md5=$(jq -r '.checksum_md5' "$metadata_file") + meta_entry="" + if [[ -f "$manifest_file" ]]; then + meta_entry=$(jq -c --arg v "$version" \ + '.manifest.versions[]? | select(.version == $v)' "$manifest_file" 2>/dev/null || true) + fi + + if [[ -n "$meta_entry" ]]; then + commit_sha_short=$(echo "$meta_entry" | jq -r '.commit_sha_short // empty') + commit_sha=$(echo "$meta_entry" | jq -r '.commit_sha // empty') + build_timestamp=$(echo "$meta_entry" | jq -r '.build_timestamp // empty') + checksum_md5=$(echo "$meta_entry" | jq -r '.checksum_md5 // empty') + checksum_sha256=$(echo "$meta_entry" | jq -r '.checksum_sha256 // empty') build_date=$(fmt_date "$build_timestamp") - echo "| \`$version\` | [Download](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${zip_basename}) | $build_date | [\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha}) | \`$checksum_md5\` |" + commit_cell="-" + [[ -n "$commit_sha" ]] && commit_cell="[\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha})" + echo "| \`$version\` | [Download](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${zip_basename}) | ${build_date:--} | $commit_cell | ${checksum_md5:--} | ${checksum_sha256:--} |" else - echo "| \`$version\` | [Download](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${zip_basename}) | - | - | - |" + echo "| \`$version\` | [Download](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${zip_basename}) | - | - | - |" fi - done < <(ls -1 "releases/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ + done < <(ls -1 "zips/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ | grep -v latest | sort -t- -k2 -V -r) echo "" @@ -116,7 +128,7 @@ for plugin_dir in plugins/*/; do echo "" echo "**Source:** [Browse Plugin](https://github.com/${GITHUB_REPOSITORY}/tree/$SOURCE_BRANCH/plugins/${plugin_name})" echo "" - echo "**Metadata:** [View full metadata](../../metadata/${plugin_name}/manifest.json)" + echo "**Metadata:** [View full manifest](./manifest.json)" if [[ "$has_readme" == "true" ]]; then echo "" @@ -126,7 +138,7 @@ for plugin_dir in plugins/*/; do echo "" cat "$plugin_dir/README.md" fi - } > "releases/$plugin_name/README.md" + } > "zips/$plugin_name/README.md" echo " $plugin_name" done diff --git a/.github/scripts/publish/releases-readme.sh b/.github/scripts/publish/releases-readme.sh index 9b3e203..98de7f9 100644 --- a/.github/scripts/publish/releases-readme.sh +++ b/.github/scripts/publish/releases-readme.sh @@ -27,12 +27,12 @@ render_plugin() { local version_count=${11} local license=${12} - local zip_url="https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${plugin_name}-latest.zip" + local zip_url="https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${plugin_name}-latest.zip" local source_url="https://github.com/${GITHUB_REPOSITORY}/tree/$SOURCE_BRANCH/plugins/${plugin_name}" local readme_url="https://github.com/${GITHUB_REPOSITORY}/blob/$SOURCE_BRANCH/plugins/${plugin_name}/README.md" - local releases_readme_url="https://github.com/${GITHUB_REPOSITORY}/blob/$RELEASES_BRANCH/releases/${plugin_name}/README.md" + local releases_readme_url="https://github.com/${GITHUB_REPOSITORY}/blob/$RELEASES_BRANCH/zips/${plugin_name}/README.md" local commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha}" - local releases_dir="./releases/${plugin_name}" + local releases_dir="./zips/${plugin_name}" local has_source_readme=false [[ -f "plugins/$plugin_name/README.md" ]] && has_source_readme=true @@ -73,8 +73,7 @@ render_plugin() { echo "## Quick Access" echo "" echo "- [manifest.json](./manifest.json) - Complete plugin registry with metadata" - echo "- [releases/](./releases/) - All plugin ZIP files" - echo "- [metadata/](./metadata/) - Version metadata with checksums" + echo "- [zips/](./zips/) - Plugin ZIP files and per-plugin manifests" echo "" echo "## Available Plugins" echo "" @@ -130,7 +129,7 @@ render_plugin() { || date -u +"%Y-%m-%dT%H:%M:%SZ") commit_sha=$(git log -1 --format=%H origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null || echo "unknown") commit_sha_short=$(git log -1 --format=%h origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null || echo "unknown") - version_count=$(ls -1 "releases/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ + version_count=$(ls -1 "zips/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ | grep -v latest | wc -l | tr -d ' ') plugin_license=$(jq -r '.license // ""' "$plugin_file") @@ -174,7 +173,7 @@ render_plugin() { || date -u +"%Y-%m-%dT%H:%M:%SZ") commit_sha=$(git log -1 --format=%H origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null || echo "unknown") commit_sha_short=$(git log -1 --format=%h origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null || echo "unknown") - version_count=$(ls -1 "releases/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ + version_count=$(ls -1 "zips/$plugin_name/${plugin_name}"-*.zip 2>/dev/null \ | grep -v latest | wc -l | tr -d ' ') plugin_license=$(jq -r '.license // ""' "$plugin_file") diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index 775b05e..d4d0d47 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -26,9 +26,11 @@ export SOURCE_BRANCH RELEASES_BRANCH MAX_VERSIONED_ZIPS echo "Publishing plugins from $SOURCE_BRANCH to $RELEASES_BRANCH" -# Create temporary working directory +# Create temporary working directories WORK_DIR=$(mktemp -d) -trap "rm -rf $WORK_DIR" EXIT +BUILD_META_DIR=$(mktemp -d) +export BUILD_META_DIR +trap 'rm -rf "$WORK_DIR" "$BUILD_META_DIR"' EXIT echo "Cloning repository..." git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$WORK_DIR/repo" @@ -40,7 +42,13 @@ git config user.email "github-actions[bot]@users.noreply.github.com" # Checkout or create releases branch echo "Setting up $RELEASES_BRANCH branch..." -if git ls-remote --exit-code --heads origin $RELEASES_BRANCH >/dev/null 2>&1; then +if [[ "${FORCE_REBUILD:-false}" == "true" ]]; then + echo "Force rebuild requested - resetting $RELEASES_BRANCH to a new orphan commit." + git checkout --orphan $RELEASES_BRANCH + git rm -rf . 2>/dev/null || true + git commit --allow-empty -m "Initialize $RELEASES_BRANCH branch (force rebuild)" + git push --force "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" $RELEASES_BRANCH +elif git ls-remote --exit-code --heads origin $RELEASES_BRANCH >/dev/null 2>&1; then git checkout $RELEASES_BRANCH git pull origin $RELEASES_BRANCH || true else @@ -57,17 +65,13 @@ echo "Fetching plugins from $SOURCE_BRANCH..." git fetch origin $SOURCE_BRANCH git checkout origin/$SOURCE_BRANCH -- plugins -mkdir -p releases +mkdir -p zips # --- Phases --- echo "" echo "=== Building ZIPs ===" bash "$SCRIPT_DIR/build-zips.sh" -echo "" -echo "=== Generating per-plugin READMEs ===" -bash "$SCRIPT_DIR/plugin-readmes.sh" - echo "" echo "=== Cleaning up old releases ===" bash "$SCRIPT_DIR/cleanup.sh" @@ -76,6 +80,10 @@ echo "" echo "=== Generating manifests ===" bash "$SCRIPT_DIR/generate-manifest.sh" +echo "" +echo "=== Generating per-plugin READMEs ===" +bash "$SCRIPT_DIR/plugin-readmes.sh" + echo "" echo "=== Generating releases README ===" bash "$SCRIPT_DIR/releases-readme.sh" @@ -85,7 +93,7 @@ echo "" echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true -git add releases metadata manifest.json README.md +git add zips manifest.json README.md if git diff --cached --quiet; then echo "No changes to commit." diff --git a/.github/scripts/retract/run.sh b/.github/scripts/retract/run.sh deleted file mode 100644 index 852e71d..0000000 --- a/.github/scripts/retract/run.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/bin/bash -set -e - -# retract-plugin.sh -# Removes a plugin (or a specific version) from the releases branch -# -# Usage: retract-plugin.sh [version] -# -# Arguments: -# plugin_name - Plugin folder name (e.g. my-plugin) -# version - Optional: specific version to retract (e.g. 1.2.3) -# If omitted, all versions are retracted. -# -# Environment variables required: -# GITHUB_REPOSITORY - Full repository name (owner/repo) -# GITHUB_TOKEN - GitHub token with write access - -PLUGIN_NAME=$1 -VERSION=$2 - -if [[ -z "$PLUGIN_NAME" ]]; then - echo "Usage: $0 [version]" - exit 1 -fi - -# Input validation - prevent path traversal and shell injection -if [[ ! "$PLUGIN_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - echo "Error: plugin_name must be lowercase-kebab-case (got '${PLUGIN_NAME}')" - exit 1 -fi - -if [[ -n "$VERSION" && ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: version must be semver X.Y.Z (got '${VERSION}')" - exit 1 -fi - -RELEASES_BRANCH="releases" - -echo "Retracting plugin: $PLUGIN_NAME${VERSION:+ v$VERSION}" - -TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT - -git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$TMPDIR/repo" -cd "$TMPDIR/repo" - -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" - -if ! git ls-remote --exit-code --heads origin $RELEASES_BRANCH >/dev/null 2>&1; then - echo "Error: releases branch does not exist" - exit 1 -fi - -git checkout $RELEASES_BRANCH -git pull origin $RELEASES_BRANCH - -if [[ -n "$VERSION" ]]; then - # Retract a specific version - ZIP_FILE="releases/$PLUGIN_NAME/${PLUGIN_NAME}-${VERSION}.zip" - META_FILE="metadata/$PLUGIN_NAME/${PLUGIN_NAME}-${VERSION}.json" - - if [[ ! -f "$ZIP_FILE" ]] && [[ ! -f "$META_FILE" ]]; then - echo "Error: version $VERSION of plugin '$PLUGIN_NAME' not found on $RELEASES_BRANCH branch" - exit 1 - fi - - rm -f "$ZIP_FILE" "$META_FILE" - echo "Removed: $ZIP_FILE" - - # Update latest.zip to point to the next most recent remaining version - NEXT_ZIP=$(ls -1 "releases/$PLUGIN_NAME/${PLUGIN_NAME}"-*.zip 2>/dev/null \ - | grep -v '\-latest\.zip' | sort -V -r | head -1 || true) - if [[ -n "$NEXT_ZIP" ]]; then - cp "$NEXT_ZIP" "releases/$PLUGIN_NAME/${PLUGIN_NAME}-latest.zip" - echo "Updated latest.zip -> $(basename "$NEXT_ZIP")" - else - rm -f "releases/$PLUGIN_NAME/${PLUGIN_NAME}-latest.zip" - echo "No remaining versions - removed latest.zip" - fi - - # Remove this version from the manifest - if [[ -f manifest.json ]]; then - jq --arg slug "$PLUGIN_NAME" --arg version "$VERSION" ' - .plugins |= map( - if .slug == $slug then - .versions |= map(select(.version != $version)) - else . - end - )' manifest.json > manifest.tmp && mv manifest.tmp manifest.json - fi - - COMMIT_MSG="Retract $PLUGIN_NAME v$VERSION" - -else - # Retract all versions of the plugin - if [[ ! -d "releases/$PLUGIN_NAME" ]] && [[ ! -d "metadata/$PLUGIN_NAME" ]]; then - echo "Error: plugin '$PLUGIN_NAME' not found on $RELEASES_BRANCH branch" - exit 1 - fi - - rm -rf "releases/$PLUGIN_NAME" "metadata/$PLUGIN_NAME" - echo "Removed releases/$PLUGIN_NAME and metadata/$PLUGIN_NAME" - - # Remove plugin from the manifest entirely - if [[ -f manifest.json ]]; then - jq --arg slug "$PLUGIN_NAME" ' - .plugins |= map(select(.slug != $slug))' manifest.json > manifest.tmp && mv manifest.tmp manifest.json - fi - - COMMIT_MSG="Retract plugin: $PLUGIN_NAME (all versions)" -fi - -git add -A -git commit -m "$COMMIT_MSG" -git push origin $RELEASES_BRANCH - -echo "Successfully retracted $PLUGIN_NAME${VERSION:+ v$VERSION} from $RELEASES_BRANCH branch" diff --git a/.github/scripts/validate/detect-changes.sh b/.github/scripts/validate/detect-changes.sh index aef7bc1..7a11ac4 100755 --- a/.github/scripts/validate/detect-changes.sh +++ b/.github/scripts/validate/detect-changes.sh @@ -169,5 +169,14 @@ if [[ $HAS_OUTSIDE_VIOLATION -eq 1 ]]; then } >> "$GITHUB_OUTPUT" fi +# Warn when an authorized contributor changes the signing public key +PUB_KEY_CHANGED=false +if [[ "$(has_write_access "$PR_AUTHOR")" -eq 1 ]]; then + if echo "$OUTSIDE_CHANGES" | grep -q "^\.github/scripts/keys/dispatcharr-plugins\.pub$"; then + PUB_KEY_CHANGED=true + fi +fi +echo "pub_key_changed=$PUB_KEY_CHANGED" >> "$GITHUB_OUTPUT" + echo "Detected $PLUGIN_COUNT plugin(s): $PLUGIN_LIST" echo "close_pr=$CLOSE_PR" diff --git a/.github/scripts/validate/report.sh b/.github/scripts/validate/report.sh index 4cd1af6..39ce957 100755 --- a/.github/scripts/validate/report.sh +++ b/.github/scripts/validate/report.sh @@ -108,8 +108,6 @@ done if [[ -n "${OUTSIDE_FILES:-}" ]]; then OVERALL_FAILED=1 - # echo "" - # echo "## Unauthorized File Modification" echo "" echo "⚠️ This PR modifies files outside of \`plugins/\`, which requires write access to the repository. These changes will block merging." echo "" @@ -126,6 +124,21 @@ done echo "" fi + if [[ "${PUB_KEY_CHANGED:-}" == "true" ]]; then + echo "" + echo "---" + echo "" + echo "### ⚠️ Signing Key Change Detected" + echo "" + echo "This PR modifies \`.github/scripts/keys/dispatcharr-plugins.pub\`. This is the public GPG key used by Dispatcharr to verify manifest signatures." + echo "" + echo "**Before merging, confirm:**" + echo "- The corresponding private key and passphrase secrets (\`GPG_PRIVATE_KEY\`, \`GPG_PASSPHRASE\`) have been updated in the repository settings." + echo "- The new public key has been bundled into the Dispatcharr application." + echo "- Existing \`.sig\` files on the \`releases\` branch will be regenerated on next publish." + echo "" + fi + CODEQL_SCAN_URL="https://github.com/${GITHUB_REPOSITORY}/security/code-scanning?query=is%3Aopen+pr%3A${PR_NUMBER}" if [[ -n "${CODEQL_RESULT:-}" && "${CODEQL_RESULT:-}" != "skipped" && "${CODEQL_RESULT:-}" != "success" ]]; then # echo "" diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 82a9471..9fdaea7 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -19,7 +19,19 @@ on: - '.github/scripts/publish/cleanup.sh' - '.github/scripts/publish/generate-manifest.sh' - '.github/scripts/publish/releases-readme.sh' + - '.github/scripts/keys/**' workflow_dispatch: + inputs: + force_rebuild: + description: 'Wipe and recreate the releases branch from scratch. WARNING: purges all version history — only the latest version of each plugin will be retained.' + type: boolean + default: false + required: false + force_rebuild_confirm: + description: 'Type CONFIRM to proceed with force rebuild (required when force_rebuild is true)' + type: string + default: '' + required: false concurrency: group: publish-plugins @@ -43,8 +55,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + FORCE_REBUILD: ${{ inputs.force_rebuild }} + FORCE_REBUILD_CONFIRM: ${{ inputs.force_rebuild_confirm }} run: | set -o pipefail + if [[ "${FORCE_REBUILD}" == "true" && "${FORCE_REBUILD_CONFIRM}" != "CONFIRM" ]]; then + echo "::error::force_rebuild requires typing CONFIRM in the force_rebuild_confirm input." + exit 1 + fi .github/scripts/publish/run.sh ${{ github.ref_name }} 2>&1 | tee publish.log - name: Generate job summary diff --git a/.github/workflows/retract-plugin.yml b/.github/workflows/retract-plugin.yml deleted file mode 100644 index 967a686..0000000 --- a/.github/workflows/retract-plugin.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Retract Plugin - -on: - workflow_dispatch: - inputs: - plugin_name: - description: 'Plugin folder name to retract (e.g. my-plugin)' - required: true - type: string - version: - description: 'Specific version to retract (e.g. 1.2.3). Leave empty to retract all versions.' - required: false - type: string - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - retract: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Make retract script executable - run: chmod +x .github/scripts/retract/run.sh - - - name: Retract plugin - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - .github/scripts/retract/run.sh \ - "${{ inputs.plugin_name }}" \ - "${{ inputs.version }}" diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index e67fd73..903e3f8 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -57,6 +57,7 @@ jobs: outside_files: ${{ steps.detect.outputs.outside_files }} outside_violation: ${{ steps.detect.outputs.outside_violation }} skip_validation: ${{ steps.detect.outputs.skip_validation }} + pub_key_changed: ${{ steps.detect.outputs.pub_key_changed }} steps: - name: Checkout base branch scripts (trusted) uses: actions/checkout@v4 @@ -443,6 +444,7 @@ jobs: CODEQL_WARNINGS: ${{ needs.codeql-analyze.outputs.codeql_warnings }} CODEQL_MEDIUMS: ${{ needs.codeql-analyze.outputs.codeql_mediums }} OUTSIDE_FILES: ${{ needs.detect-changes.outputs.outside_files }} + PUB_KEY_CHANGED: ${{ needs.detect-changes.outputs.pub_key_changed }} run: | chmod +x .github/scripts/validate/*.sh set +e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af8d2e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Plugin Signature Files +.github/scripts/keys/*.key +.github/scripts/keys/*.pass + +# macOS +**/.DS_Store +**/.AppleDouble +**/.LSOverride + +# IDE +**/.vscode/ +**/.idea/ +**/*.sublime-project +**/*.sublime-workspace + +# Python +**/__pycache__/ +**/*.py[cod] +**/*.pyo +**/*.pyd +**/*.egg +**/*.egg-info/ +**/dist/ +**/build/ +**/.eggs/ +**/.env +**/.venv +**/env/ +**/venv/ +**/*.env.local +**/.Python +**/pip-log.txt +**/pip-delete-this-directory.txt +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ +**/htmlcov/ +**/.coverage +**/.coverage.* +**/coverage.xml +**/*.log + +# Shell +**/*.swp +**/*.swo +**/*~ + +# Docker +**/.dockerignore +**/docker-compose.override.yml +**/.docker/ + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba31e5d..63f17ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ At least one of `author` or `maintainers` must include your GitHub username. `au | `repo_url` | `string` | URL to the plugin's source repository (must start with `http://` or `https://`) | | `discord_thread` | `string` | URL to the associated Discord thread (must start with `http://` or `https://`) | | `deprecated` | `boolean` | Marks the plugin as deprecated. Default: `false` | -| `unlisted` | `boolean` | Hides the plugin from the releases README. Default: `false` | +| `unlisted` | `boolean` | Excludes the plugin from `manifest.json` and hides it from the releases README. Default: `false` | ### Full Example @@ -111,8 +111,8 @@ PRs where the author has no permission for any of the modified plugins are **aut Once your PR merges to `main`, the publish workflow runs automatically: 1. Your plugin is packaged into a versioned ZIP (`your-plugin-1.0.0.zip`) and a latest ZIP (`your-plugin-latest.zip`) -2. An MD5 checksum is computed -3. A per-plugin `releases/your-plugin-name/README.md` is generated with download links and version history +2. MD5 and SHA256 checksums are computed +3. A per-plugin `zips/your-plugin-name/README.md` is generated with download links and version history 4. `manifest.json` is updated with your plugin's metadata and download URLs 5. The releases branch README is regenerated 6. Up to 10 versioned ZIPs are retained; older ones are pruned diff --git a/README.md b/README.md index 9eb0f24..8bd608f 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,8 @@ A repository for publishing and distributing Dispatcharr Python plugins with aut | Resource | Description | |----------|-------------| | [Browse Plugins](https://github.com/Dispatcharr/Plugins/tree/releases) | All available plugins on the releases branch | -| [Plugin Manifest](https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json) | Plugin metadata, checksums, and download URLs | -| [Download Releases](https://github.com/Dispatcharr/Plugins/tree/releases/releases) | Plugin ZIP files | -| [View Metadata](https://github.com/Dispatcharr/Plugins/tree/releases/metadata) | Version metadata with commit info and checksums | +| [Plugin Manifest](https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json) | Root plugin index with metadata and download URLs | +| [Download Releases](https://github.com/Dispatcharr/Plugins/tree/releases/zips) | Plugin ZIP files and per-plugin manifests | ## How It Works @@ -51,4 +50,49 @@ Visit the [releases branch](https://github.com/Dispatcharr/Plugins/tree/releases ```bash curl https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json -``` \ No newline at end of file +``` + +## Verifying Manifest Signatures + +Each manifest file embeds its GPG signature directly. The `signature` field covers the compact (`jq -c '.manifest'`) form of the `manifest` payload: + +```json +{ + "generated_at": "...", + "repo_url": "...", + "repo_name": "owner/repo", + "signature": "-----BEGIN PGP SIGNATURE-----\n...", + "manifest": { ... } +} +``` + +The public key is bundled with Dispatcharr. To verify manually, export it from the application or obtain `.github/scripts/keys/dispatcharr-plugins.pub` from the default branch. + +### Steps + +**1. Import the public key** + +```bash +gpg --import dispatcharr-plugins.pub +``` + +**2. Download the manifest** + +```bash +curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json +``` + +**3. Verify** + +```bash +jq -c '.manifest' manifest.json | gpg --verify <(jq -r '.signature' manifest.json) - +``` + +A successful result looks like: + +``` +gpg: Signature made ... +gpg: Good signature from "..." [full] +``` + +The same steps apply to any per-plugin manifest - substitute the path to `zips//manifest.json`. \ No newline at end of file