From e4a15fce0bc48a901d90503be68ed8dce8b033b3 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:08:08 -0400 Subject: [PATCH 01/30] Implement GPG signing for plugin manifests and update README with verification steps --- .github/scripts/keys/dispatcharr-plugins.pub | 10 ++++ .github/scripts/keys/generate-signing-key.sh | 46 +++++++++++++++++ .github/scripts/publish/generate-manifest.sh | 39 +++++++++++++- .github/workflows/publish-plugins.yml | 2 + .gitignore | 53 ++++++++++++++++++++ README.md | 47 ++++++++++++++++- 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/keys/dispatcharr-plugins.pub create mode 100755 .github/scripts/keys/generate-signing-key.sh create mode 100644 .gitignore diff --git a/.github/scripts/keys/dispatcharr-plugins.pub b/.github/scripts/keys/dispatcharr-plugins.pub new file mode 100644 index 0000000..dc55883 --- /dev/null +++ b/.github/scripts/keys/dispatcharr-plugins.pub @@ -0,0 +1,10 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEab7rTxYJKwYBBAHaRw8BAQdAs4Zu9I7GiG4+dbAZc7WbarQndqVuFHEKiC6w +wMbXe0W0MERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIDxwbHVnaW5zQGRpc3BhdGNo +YXJyLnR2PoivBBMWCgBXFiEEQD9gGEa2W44k130qAKLZ2RzQSQAFAmm+608bFIAA +AAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsDBQsJCAcCAiICBhUKCQgLAgQWAgMB +Ah4HAheAAAoJEACi2dkc0EkAsC0BALJpbyUZVa5wJe/jYWZiRDHrJ7ju0TrXh33o +WQrrmLl0AP9CQYGFCGJe+G+P2EC3DR5Ij3uDSck9quB+ZNzJb9ITDQ== +=vw8N +-----END PGP PUBLIC KEY BLOCK----- diff --git a/.github/scripts/keys/generate-signing-key.sh b/.github/scripts/keys/generate-signing-key.sh new file mode 100755 index 0000000..ce8beae --- /dev/null +++ b/.github/scripts/keys/generate-signing-key.sh @@ -0,0 +1,46 @@ +#!/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 --gen-key < dispatcharr-plugins.key +gpg --armor --export "$EMAIL" > dispatcharr-plugins.pub +echo "$PASSPHRASE" > dispatcharr-plugins.pass + +echo "" +echo "Files written:" +echo " dispatcharr-plugins.pub → bundle into Dispatcharr app" +echo " dispatcharr-plugins.key → set as GPG_PRIVATE_KEY repo secret" +echo " dispatcharr-plugins.pass → set as GPG_PASSPHRASE repo secret" +echo "" +echo "Delete dispatcharr-plugins.key and dispatcharr-plugins.pass after" +echo "uploading to GitHub secrets. Keep dispatcharr-plugins.pub in source control." diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index a2838d7..56c3833 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -9,6 +9,31 @@ set -e : "${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="" +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) + echo "GPG signing enabled (key: $gpg_key_id)" +fi + +# Signs $1 (a JSON file) and writes an armored detached signature to $1.sig. +# The signed payload is the compact JSON of the file. +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 "${file}.sig") + if [[ -n "${GPG_PASSPHRASE:-}" ]]; then + gpg_opts+=(--passphrase "$GPG_PASSPHRASE" --pinentry-mode loopback) + fi + jq -c '.' "$file" | gpg "${gpg_opts[@]}" 2>/dev/null +} + plugin_entries=() root_entries=() @@ -80,7 +105,12 @@ for plugin_dir in plugins/*/; do )' \ "$plugin_file") - echo "$plugin_entry" | jq '.' > "metadata/$plugin_name/manifest.json" + echo "$plugin_entry" | jq \ + --arg ts "$generated_at" \ + --arg repo_url "$repo_url" \ + --arg repo_name "$repo_name" \ + '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' > "metadata/$plugin_name/manifest.json" + sign_manifest "metadata/$plugin_name/manifest.json" plugin_entries+=("$plugin_entry") # Compact root manifest entry @@ -130,6 +160,11 @@ done echo "" echo ' ]' echo '}' -} | jq --arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" '{generated_at: $ts} + .' > manifest.json +} | jq \ + --arg ts "$generated_at" \ + --arg repo_url "$repo_url" \ + --arg repo_name "$repo_name" \ + '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' > manifest.json +sign_manifest "manifest.json" echo "Generated manifest.json with ${#root_entries[@]} plugin(s)." diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 82a9471..1f1e6f8 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -43,6 +43,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | set -o pipefail .github/scripts/publish/run.sh ${{ github.ref_name }} 2>&1 | tee publish.log 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/README.md b/README.md index 9eb0f24..5c09185 100644 --- a/README.md +++ b/README.md @@ -51,4 +51,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 is GPG-signed. A detached armored signature is published alongside it as a `.sig` sidecar file: + +| File | URL | +|------|-----| +| Root manifest | `.../releases/manifest.json` | +| Root signature | `.../releases/manifest.json.sig` | +| Plugin manifest | `.../releases/metadata//manifest.json` | +| Plugin signature | `.../releases/metadata//manifest.json.sig` | + +### Steps + +**1. Import the public key** + +The public key is bundled with Dispatcharr. To verify manually, export it from the application or obtain it from the repository and import it: + +```bash +gpg --import dispatcharr-plugins.pub +``` + +**2. Download the manifest and its signature** + +```bash +curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json +curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json.sig +``` + +**3. Verify** + +The signature covers the compact (`jq -c '.'`) form of the JSON: + +```bash +jq -c '.' manifest.json | gpg --verify manifest.json.sig - +``` + +A successful result looks like: + +``` +gpg: Signature made ... +gpg: Good signature from "..." [full] +``` + +The same steps apply to any per-plugin manifest - just substitute the path to `metadata//manifest.json`. \ No newline at end of file From 26737b6498d81e22f60bc13bf8126b065f679553 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:10:01 -0400 Subject: [PATCH 02/30] Add missing GPG signature for manifest in publish script --- .github/scripts/publish/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index 775b05e..1b84111 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -85,7 +85,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 releases metadata manifest.json manifest.json.sig README.md if git diff --cached --quiet; then echo "No changes to commit." From 41dc09aa4d3de80de3a674a0b0f5ac7026df185e Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:20:47 -0400 Subject: [PATCH 03/30] Refactor GPG key generation script to ensure keys are exported to the correct directory and update output messages for clarity --- .github/scripts/keys/generate-signing-key.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/scripts/keys/generate-signing-key.sh b/.github/scripts/keys/generate-signing-key.sh index ce8beae..ff7f4e5 100755 --- a/.github/scripts/keys/generate-signing-key.sh +++ b/.github/scripts/keys/generate-signing-key.sh @@ -10,6 +10,7 @@ set -e EMAIL="plugins@dispatcharr.tv" NAME="Dispatcharr Plugin Repo" PASSPHRASE=$(LC_ALL=C tr -dc 'A-Za-z0-9!@#$%^&*' " @@ -32,15 +33,14 @@ echo "" gpg --list-secret-keys --keyid-format LONG "$EMAIL" # Export keys -gpg --armor --export-secret-keys "$EMAIL" > dispatcharr-plugins.key -gpg --armor --export "$EMAIL" > dispatcharr-plugins.pub -echo "$PASSPHRASE" > dispatcharr-plugins.pass +rm -f "$KEYS_DIR/dispatcharr-plugins.key" "$KEYS_DIR/dispatcharr-plugins.pub" "$KEYS_DIR/dispatcharr-plugins.pass" +gpg --armor --export-secret-keys "$EMAIL" > "$KEYS_DIR/dispatcharr-plugins.key" +gpg --armor --export "$EMAIL" > "$KEYS_DIR/dispatcharr-plugins.pub" +echo "$PASSPHRASE" > "$KEYS_DIR/dispatcharr-plugins.pass" echo "" echo "Files written:" -echo " dispatcharr-plugins.pub → bundle into Dispatcharr app" -echo " dispatcharr-plugins.key → set as GPG_PRIVATE_KEY repo secret" -echo " dispatcharr-plugins.pass → set as GPG_PASSPHRASE repo secret" +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 "" -echo "Delete dispatcharr-plugins.key and dispatcharr-plugins.pass after" -echo "uploading to GitHub secrets. Keep dispatcharr-plugins.pub in source control." From 79b3c5fa05e29a2cb3a4d04fbc1bc7c24d074224 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:22:16 -0400 Subject: [PATCH 04/30] Increase passphrase length in GPG key generation script for enhanced security --- .github/scripts/keys/generate-signing-key.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/keys/generate-signing-key.sh b/.github/scripts/keys/generate-signing-key.sh index ff7f4e5..9f84a3d 100755 --- a/.github/scripts/keys/generate-signing-key.sh +++ b/.github/scripts/keys/generate-signing-key.sh @@ -9,7 +9,7 @@ set -e EMAIL="plugins@dispatcharr.tv" NAME="Dispatcharr Plugin Repo" -PASSPHRASE=$(LC_ALL=C tr -dc 'A-Za-z0-9!@#$%^&*' Date: Sat, 21 Mar 2026 15:35:37 -0400 Subject: [PATCH 05/30] Update GPG key generation script to enhance security and improve key export process --- .github/scripts/keys/dispatcharr-plugins.pub | 15 +++++++------ .github/scripts/keys/generate-signing-key.sh | 23 +++++++++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.github/scripts/keys/dispatcharr-plugins.pub b/.github/scripts/keys/dispatcharr-plugins.pub index dc55883..a3eef51 100644 --- a/.github/scripts/keys/dispatcharr-plugins.pub +++ b/.github/scripts/keys/dispatcharr-plugins.pub @@ -1,10 +1,11 @@ -----BEGIN PGP PUBLIC KEY BLOCK----- -mDMEab7rTxYJKwYBBAHaRw8BAQdAs4Zu9I7GiG4+dbAZc7WbarQndqVuFHEKiC6w -wMbXe0W0MERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIDxwbHVnaW5zQGRpc3BhdGNo -YXJyLnR2PoivBBMWCgBXFiEEQD9gGEa2W44k130qAKLZ2RzQSQAFAmm+608bFIAA -AAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsDBQsJCAcCAiICBhUKCQgLAgQWAgMB -Ah4HAheAAAoJEACi2dkc0EkAsC0BALJpbyUZVa5wJe/jYWZiRDHrJ7ju0TrXh33o -WQrrmLl0AP9CQYGFCGJe+G+P2EC3DR5Ij3uDSck9quB+ZNzJb9ITDQ== -=vw8N +mDMEab7yvRYJKwYBBAHaRw8BAQdAkU2oCAuiS3mAh0JZJKqRZsjp/3LsCCzfAnMF +23sXvx+0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv +Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBIgZ +2Ccsw7kHuv/iwfiYcskfxgqNBQJpvvK9GxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy +LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRD4mHLJH8YKjTe4 +AP9Q6DmkNefZN19/U7u9bY7OizMTPJMmT9aGFPldTc8HmQD+IDXC3nJ7Rd7pSZ1b +wjPGuCd84DxLzMePnfcIy2OTxQg= +=2pYw -----END PGP PUBLIC KEY BLOCK----- diff --git a/.github/scripts/keys/generate-signing-key.sh b/.github/scripts/keys/generate-signing-key.sh index 9f84a3d..4d70879 100755 --- a/.github/scripts/keys/generate-signing-key.sh +++ b/.github/scripts/keys/generate-signing-key.sh @@ -9,35 +9,42 @@ set -e 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 --gen-key </dev/null | awk -F: '/^fpr/{print $10}' | head -1) -# Export keys rm -f "$KEYS_DIR/dispatcharr-plugins.key" "$KEYS_DIR/dispatcharr-plugins.pub" "$KEYS_DIR/dispatcharr-plugins.pass" -gpg --armor --export-secret-keys "$EMAIL" > "$KEYS_DIR/dispatcharr-plugins.key" -gpg --armor --export "$EMAIL" > "$KEYS_DIR/dispatcharr-plugins.pub" +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)" From 480465359f4aa9021698b23c2544f346fe1d16b9 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:37:21 -0400 Subject: [PATCH 06/30] Remove retract plugin workflow and associated script --- .github/scripts/retract/run.sh | 118 -------------------------- .github/workflows/publish-plugins.yml | 1 + .github/workflows/retract-plugin.yml | 39 --------- 3 files changed, 1 insertion(+), 157 deletions(-) delete mode 100644 .github/scripts/retract/run.sh delete mode 100644 .github/workflows/retract-plugin.yml 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/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 1f1e6f8..8064349 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -19,6 +19,7 @@ on: - '.github/scripts/publish/cleanup.sh' - '.github/scripts/publish/generate-manifest.sh' - '.github/scripts/publish/releases-readme.sh' + - '.github/scripts/keys/**' workflow_dispatch: concurrency: 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 }}" From 5f8df36c8a498bb75a83c737a32255423bcf3358 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:49:03 -0400 Subject: [PATCH 07/30] Enhance GPG signing process and add public key change detection in validation scripts --- .github/scripts/publish/generate-manifest.sh | 26 +++++++++++++++++--- .github/scripts/validate/detect-changes.sh | 9 +++++++ .github/scripts/validate/report.sh | 17 +++++++++++-- .github/workflows/validate-plugin.yml | 2 ++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 56c3833..31a4db1 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -15,15 +15,23 @@ 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) - echo "GPG signing enabled (key: $gpg_key_id)" + 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 # Signs $1 (a JSON file) and writes an armored detached signature to $1.sig. -# The signed payload is the compact JSON of the file. +# Sets gpg_signing_failed=1 on any gpg error so all sigs are cleaned up at the end. sign_manifest() { local file="$1" [[ -z "$gpg_key_id" ]] && return 0 @@ -31,7 +39,11 @@ sign_manifest() { if [[ -n "${GPG_PASSPHRASE:-}" ]]; then gpg_opts+=(--passphrase "$GPG_PASSPHRASE" --pinentry-mode loopback) fi - jq -c '.' "$file" | gpg "${gpg_opts[@]}" 2>/dev/null + if ! jq -c '.' "$file" | gpg "${gpg_opts[@]}" 2>/dev/null; then + echo "::warning::GPG signing failed for ${file} - all signatures will be removed." + gpg_signing_failed=1 + rm -f "${file}.sig" + fi } plugin_entries=() @@ -167,4 +179,12 @@ done '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' > manifest.json sign_manifest "manifest.json" +# If any signing step failed, remove ALL .sig files so the repo is never +# left in a partially-signed state. +if [[ "$gpg_signing_failed" -eq 1 ]]; then + echo "::warning::Removing all .sig files due to signing failure." + find metadata -name "*.sig" -delete 2>/dev/null || true + rm -f manifest.json.sig +fi + echo "Generated manifest.json with ${#root_entries[@]} plugin(s)." 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/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 From 654c8191a7fbf02d263a01fd9213c3037ae6662a Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:53:57 -0400 Subject: [PATCH 08/30] Implement manifest writing and signing functions to optimize JSON generation and signature validation --- .github/scripts/publish/generate-manifest.sh | 80 +++++++++++++++----- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 31a4db1..8c8044d 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -30,6 +30,34 @@ else echo "GPG_PRIVATE_KEY not set - signatures will be skipped." fi +# Writes $2 (JSON string) to $1 only when the content (excluding generated_at) +# differs from the file already on disk. Returns 0 if written, 1 if skipped. +write_manifest_if_changed() { + local dest="$1" new_content="$2" + if [[ -f "$dest" ]]; then + local existing_stripped new_stripped + existing_stripped=$(jq -c 'del(.generated_at)' "$dest") + new_stripped=$(echo "$new_content" | jq -c 'del(.generated_at)') + if [[ "$existing_stripped" == "$new_stripped" ]]; then + return 1 + fi + fi + echo "$new_content" > "$dest" + return 0 +} + +# Returns 0 if $1.sig exists and was created by the current gpg_key_id, 1 otherwise. +# Used to detect key rotation: if the key changed we must re-sign even when content is unchanged. +sig_is_current() { + local file="$1" + [[ -f "${file}.sig" ]] || return 1 + local sig_fpr + sig_fpr=$(jq -c '.' "$file" | gpg --verify --status-fd 1 "${file}.sig" - 2>/dev/null \ + | awk '/VALIDSIG/{print $3}' | head -1) + # gpg_key_id is a 16-char long key ID; VALIDSIG gives the full 40-char fingerprint + [[ -n "$sig_fpr" && "$sig_fpr" == *"$gpg_key_id" ]] && return 0 || return 1 +} + # Signs $1 (a JSON file) and writes an armored detached signature to $1.sig. # Sets gpg_signing_failed=1 on any gpg error so all sigs are cleaned up at the end. sign_manifest() { @@ -117,12 +145,16 @@ for plugin_dir in plugins/*/; do )' \ "$plugin_file") - echo "$plugin_entry" | jq \ + new_plugin_manifest=$(echo "$plugin_entry" | jq \ --arg ts "$generated_at" \ --arg repo_url "$repo_url" \ --arg repo_name "$repo_name" \ - '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' > "metadata/$plugin_name/manifest.json" - sign_manifest "metadata/$plugin_name/manifest.json" + '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .') + if write_manifest_if_changed "metadata/$plugin_name/manifest.json" "$new_plugin_manifest"; then + sign_manifest "metadata/$plugin_name/manifest.json" + elif [[ -n "$gpg_key_id" ]] && ! sig_is_current "metadata/$plugin_name/manifest.json"; then + sign_manifest "metadata/$plugin_name/manifest.json" + fi plugin_entries+=("$plugin_entry") # Compact root manifest entry @@ -160,24 +192,30 @@ for plugin_dir in plugins/*/; do 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 "$generated_at" \ - --arg repo_url "$repo_url" \ - --arg repo_name "$repo_name" \ - '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' > manifest.json -sign_manifest "manifest.json" +new_root_manifest=$( + { + 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 "$generated_at" \ + --arg repo_url "$repo_url" \ + --arg repo_name "$repo_name" \ + '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' +) +if write_manifest_if_changed "manifest.json" "$new_root_manifest"; then + sign_manifest "manifest.json" +elif [[ -n "$gpg_key_id" ]] && ! sig_is_current "manifest.json"; then + sign_manifest "manifest.json" +fi # If any signing step failed, remove ALL .sig files so the repo is never # left in a partially-signed state. From c95f19ef8e41214c0a2e2c5dd4454c472f314abf Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 15:55:29 -0400 Subject: [PATCH 09/30] Fix GPG signing condition and update manifest.json.sig handling in publish scripts --- .github/scripts/publish/generate-manifest.sh | 4 ++-- .github/scripts/publish/run.sh | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 8c8044d..aae4483 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -152,7 +152,7 @@ for plugin_dir in plugins/*/; do '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .') if write_manifest_if_changed "metadata/$plugin_name/manifest.json" "$new_plugin_manifest"; then sign_manifest "metadata/$plugin_name/manifest.json" - elif [[ -n "$gpg_key_id" ]] && ! sig_is_current "metadata/$plugin_name/manifest.json"; then + elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "metadata/$plugin_name/manifest.json"; then sign_manifest "metadata/$plugin_name/manifest.json" fi plugin_entries+=("$plugin_entry") @@ -213,7 +213,7 @@ new_root_manifest=$( ) if write_manifest_if_changed "manifest.json" "$new_root_manifest"; then sign_manifest "manifest.json" -elif [[ -n "$gpg_key_id" ]] && ! sig_is_current "manifest.json"; then +elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "manifest.json"; then sign_manifest "manifest.json" fi diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index 1b84111..cb79c52 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -85,7 +85,9 @@ echo "" echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true -git add releases metadata manifest.json manifest.json.sig README.md +git add releases metadata manifest.json README.md +# manifest.json.sig only exists when GPG signing succeeded +[[ -f manifest.json.sig ]] && git add manifest.json.sig || git rm --cached --ignore-unmatch manifest.json.sig if git diff --cached --quiet; then echo "No changes to commit." From bd26f7c21ff9d5727dce8520e4604d01779e5962 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:12:35 -0400 Subject: [PATCH 10/30] Refactor manifest handling to embed GPG signatures directly and update README for verification steps --- .github/scripts/publish/generate-manifest.sh | 105 ++++++++++++------- .github/scripts/publish/run.sh | 2 - README.md | 32 +++--- 3 files changed, 82 insertions(+), 57 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index aae4483..163d9df 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -11,7 +11,7 @@ set -e generated_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" repo_url="https://github.com/${GITHUB_REPOSITORY}" -repo_name="${GITHUB_REPOSITORY##*/}" +repo_name="${GITHUB_REPOSITORY}" # GPG signing setup - optional; set GPG_PRIVATE_KEY (armored) and optionally GPG_PASSPHRASE gpg_key_id="" @@ -30,47 +30,76 @@ else echo "GPG_PRIVATE_KEY not set - signatures will be skipped." fi -# Writes $2 (JSON string) to $1 only when the content (excluding generated_at) -# differs from the file already on disk. Returns 0 if written, 1 if skipped. +# 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" new_content="$2" + local dest="$1" manifest_payload="$2" + local new_compact + new_compact=$(echo "$manifest_payload" | jq -c '.') if [[ -f "$dest" ]]; then - local existing_stripped new_stripped - existing_stripped=$(jq -c 'del(.generated_at)' "$dest") - new_stripped=$(echo "$new_content" | jq -c 'del(.generated_at)') - if [[ "$existing_stripped" == "$new_stripped" ]]; then + local existing_manifest + existing_manifest=$(jq -c '.manifest' "$dest" 2>/dev/null) + if [[ "$existing_manifest" == "$new_compact" ]]; then return 1 fi fi - echo "$new_content" > "$dest" + 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 $1.sig exists and was created by the current gpg_key_id, 1 otherwise. -# Used to detect key rotation: if the key changed we must re-sign even when content is unchanged. +# 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" - [[ -f "${file}.sig" ]] || return 1 - local sig_fpr - sig_fpr=$(jq -c '.' "$file" | gpg --verify --status-fd 1 "${file}.sig" - 2>/dev/null \ - | awk '/VALIDSIG/{print $3}' | head -1) - # gpg_key_id is a 16-char long key ID; VALIDSIG gives the full 40-char fingerprint - [[ -n "$sig_fpr" && "$sig_fpr" == *"$gpg_key_id" ]] && return 0 || return 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 $1 (a JSON file) and writes an armored detached signature to $1.sig. -# Sets gpg_signing_failed=1 on any gpg error so all sigs are cleaned up at the end. +# 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 "${file}.sig") + 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 - if ! jq -c '.' "$file" | gpg "${gpg_opts[@]}" 2>/dev/null; then + 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 - rm -f "${file}.sig" + 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 } @@ -145,12 +174,7 @@ for plugin_dir in plugins/*/; do )' \ "$plugin_file") - new_plugin_manifest=$(echo "$plugin_entry" | jq \ - --arg ts "$generated_at" \ - --arg repo_url "$repo_url" \ - --arg repo_name "$repo_name" \ - '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .') - if write_manifest_if_changed "metadata/$plugin_name/manifest.json" "$new_plugin_manifest"; then + if write_manifest_if_changed "metadata/$plugin_name/manifest.json" "$plugin_entry"; then sign_manifest "metadata/$plugin_name/manifest.json" elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "metadata/$plugin_name/manifest.json"; then sign_manifest "metadata/$plugin_name/manifest.json" @@ -192,7 +216,7 @@ for plugin_dir in plugins/*/; do root_entries+=("$root_entry") done -new_root_manifest=$( +inner_root=$( { echo '{' echo ' "plugins": [' @@ -205,24 +229,25 @@ new_root_manifest=$( echo "" echo ' ]' echo '}' - } | jq \ - --arg ts "$generated_at" \ - --arg repo_url "$repo_url" \ - --arg repo_name "$repo_name" \ - '{generated_at: $ts, repo_url: $repo_url, repo_name: $repo_name} + .' + } | jq -c '.' ) -if write_manifest_if_changed "manifest.json" "$new_root_manifest"; then +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, remove ALL .sig files so the repo is never -# left in a partially-signed state. +# If any signing step failed, strip embedded signatures from all manifests so +# the repo is never left in a partially-signed state. if [[ "$gpg_signing_failed" -eq 1 ]]; then - echo "::warning::Removing all .sig files due to signing failure." - find metadata -name "*.sig" -delete 2>/dev/null || true - rm -f manifest.json.sig + echo "::warning::Removing all signatures due to signing failure." + while IFS= read -r -d '' _f; do + _tmp=$(mktemp) + jq 'del(.signature)' "$_f" > "$_tmp" && mv "$_tmp" "$_f" || rm -f "$_tmp" + done < <(find metadata -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/run.sh b/.github/scripts/publish/run.sh index cb79c52..775b05e 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -86,8 +86,6 @@ echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true git add releases metadata manifest.json README.md -# manifest.json.sig only exists when GPG signing succeeded -[[ -f manifest.json.sig ]] && git add manifest.json.sig || git rm --cached --ignore-unmatch manifest.json.sig if git diff --cached --quiet; then echo "No changes to commit." diff --git a/README.md b/README.md index 5c09185..11d58f6 100644 --- a/README.md +++ b/README.md @@ -55,38 +55,40 @@ curl https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.jso ## Verifying Manifest Signatures -Each manifest is GPG-signed. A detached armored signature is published alongside it as a `.sig` sidecar file: +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": { ... } +} +``` -| File | URL | -|------|-----| -| Root manifest | `.../releases/manifest.json` | -| Root signature | `.../releases/manifest.json.sig` | -| Plugin manifest | `.../releases/metadata//manifest.json` | -| Plugin signature | `.../releases/metadata//manifest.json.sig` | +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** -The public key is bundled with Dispatcharr. To verify manually, export it from the application or obtain it from the repository and import it: - ```bash gpg --import dispatcharr-plugins.pub ``` -**2. Download the manifest and its signature** +**2. Download the manifest** ```bash curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json -curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json.sig ``` **3. Verify** -The signature covers the compact (`jq -c '.'`) form of the JSON: - ```bash -jq -c '.' manifest.json | gpg --verify manifest.json.sig - +jq -r '.signature' manifest.json > manifest.json.sig +jq -c '.manifest' manifest.json | gpg --verify manifest.json.sig - +rm manifest.json.sig ``` A successful result looks like: @@ -96,4 +98,4 @@ gpg: Signature made ... gpg: Good signature from "..." [full] ``` -The same steps apply to any per-plugin manifest - just substitute the path to `metadata//manifest.json`. \ No newline at end of file +The same steps apply to any per-plugin manifest — substitute the path to `metadata//manifest.json`. \ No newline at end of file From 6ebfc2206a9918548fd655780eeaa1a1830e4f51 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:25:25 -0400 Subject: [PATCH 11/30] Add SHA256 checksum to plugin metadata and update README table format --- .github/scripts/publish/generate-manifest.sh | 1 + .github/scripts/publish/plugin-readmes.sh | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 163d9df..6b99566 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -209,6 +209,7 @@ 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_sha256: ($latest_metadata.checksum_sha256 // null), latest_url: $latest_url, min_dispatcharr_version: ($latest_metadata.min_dispatcharr_version // null), max_dispatcharr_version: ($latest_metadata.max_dispatcharr_version // null) diff --git a/.github/scripts/publish/plugin-readmes.sh b/.github/scripts/publish/plugin-readmes.sh index dd801a3..914da0d 100644 --- a/.github/scripts/publish/plugin-readmes.sh +++ b/.github/scripts/publish/plugin-readmes.sh @@ -90,8 +90,8 @@ 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 "|---------|----------|-------|--------|-----|--------|" while IFS= read -r zipfile; do zip_basename=$(basename "$zipfile") @@ -103,8 +103,9 @@ for plugin_dir in plugins/*/; do commit_sha=$(jq -r '.commit_sha' "$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") 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\` |" + 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\` | \`$checksum_sha256\` |" else echo "| \`$version\` | [Download](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/releases/${plugin_name}/${zip_basename}) | - | - | - |" fi From 11fa6cbf2ac4c32cfc04b270f050f14fcda6aed6 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:42:45 -0400 Subject: [PATCH 12/30] Refactor publishing scripts to use 'zips/' directory for artifacts and update README links accordingly --- .github/scripts/publish/build-zips.sh | 33 +++++++--- .github/scripts/publish/cleanup.sh | 55 ++++++++--------- .github/scripts/publish/generate-manifest.sh | 38 ++++++++---- .github/scripts/publish/plugin-readmes.sh | 65 +++++++++++--------- .github/scripts/publish/releases-readme.sh | 13 ++-- .github/scripts/publish/run.sh | 14 ++--- README.md | 11 ++-- 7 files changed, 126 insertions(+), 103 deletions(-) diff --git a/.github/scripts/publish/build-zips.sh b/.github/scripts/publish/build-zips.sh index 8a8e598..bfcbc26 100644 --- a/.github/scripts/publish/build-zips.sh +++ b/.github/scripts/publish/build-zips.sh @@ -2,8 +2,12 @@ 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. @@ -11,6 +15,10 @@ set -e : "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" +# Temp dir for this run's per-version metadata, cleaned up by run.sh trap +export BUILD_META_DIR +BUILD_META_DIR=$(mktemp -d) + > changed_plugins.txt for plugin_dir in plugins/*/; do @@ -18,14 +26,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 +57,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 +78,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..973af19 100644 --- a/.github/scripts/publish/cleanup.sh +++ b/.github/scripts/publish/cleanup.sh @@ -3,7 +3,9 @@ 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. +# Also contains LEGACY CLEANUP blocks (clearly marked) that can be removed +# once all pre-existing releases branches have been migrated. # # Called from the releases branch checkout directory by publish-plugins.sh. # Required env: SOURCE_BRANCH @@ -12,53 +14,46 @@ set -e : "${SOURCE_BRANCH:?}" MAX_VERSIONED_ZIPS=${MAX_VERSIONED_ZIPS:-10} +# ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ───── +# Removes the old metadata/ directory that pre-dated zips//manifest.json. +if [[ -d metadata ]]; then + echo " Removing legacy metadata/ directory" + rm -rf metadata +fi +# ── END LEGACY CLEANUP ────────────────────────────────────────────────────────── + # 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 and remove legacy per-version JSON files 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 + # ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ─── + # Removes per-version -.json files that pre-dated embedded manifest versions. + for legacy_file in "$zip_dir/${plugin_name}-"*.json; do + [[ -f "$legacy_file" ]] || continue + echo " Removed legacy metadata: $(basename "$legacy_file")" + rm -f "$legacy_file" done + # ── END LEGACY CLEANUP ──────────────────────────────────────────────────────── done diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 6b99566..524430f 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -2,7 +2,7 @@ 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 @@ -113,19 +113,31 @@ for plugin_dir in plugins/*/; do 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 @@ -135,7 +147,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 @@ -174,10 +186,10 @@ for plugin_dir in plugins/*/; do )' \ "$plugin_file") - if write_manifest_if_changed "metadata/$plugin_name/manifest.json" "$plugin_entry"; then - sign_manifest "metadata/$plugin_name/manifest.json" - elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "metadata/$plugin_name/manifest.json"; then - sign_manifest "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") @@ -189,7 +201,7 @@ 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" \ @@ -245,7 +257,7 @@ if [[ "$gpg_signing_failed" -eq 1 ]]; then while IFS= read -r -d '' _f; do _tmp=$(mktemp) jq 'del(.signature)' "$_f" > "$_tmp" && mv "$_tmp" "$_f" || rm -f "$_tmp" - done < <(find metadata -name "manifest.json" -print0 2>/dev/null) + 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 diff --git a/.github/scripts/publish/plugin-readmes.sh b/.github/scripts/publish/plugin-readmes.sh index 914da0d..89566c7 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,26 +53,27 @@ 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)" + 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') + commit_sha_short=$(echo "$meta_entry" | jq -r '.commit_sha_short') + build_timestamp=$(echo "$meta_entry" | jq -r '.build_timestamp') + checksum_md5=$(echo "$meta_entry" | jq -r '.checksum_md5') + checksum_sha256=$(echo "$meta_entry" | jq -r '.checksum_sha256') + + echo "- **Download:** [\`${plugin_name}-latest.zip\`](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${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 "" @@ -82,7 +83,7 @@ for plugin_dir in plugins/*/; do echo "SHA256: $checksum_sha256" echo "\`\`\`" 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 @@ -93,23 +94,29 @@ for plugin_dir in plugins/*/; do 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") - checksum_sha256=$(jq -r '.checksum_sha256' "$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') + commit_sha=$(echo "$meta_entry" | jq -r '.commit_sha') + build_timestamp=$(echo "$meta_entry" | jq -r '.build_timestamp') + checksum_md5=$(echo "$meta_entry" | jq -r '.checksum_md5') + checksum_sha256=$(echo "$meta_entry" | jq -r '.checksum_sha256') 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\` | \`$checksum_sha256\` |" + echo "| \`$version\` | [Download](https://github.com/${GITHUB_REPOSITORY}/raw/$RELEASES_BRANCH/zips/${plugin_name}/${zip_basename}) | $build_date | [\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha}) | \`$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 "" @@ -117,7 +124,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 "" @@ -127,7 +134,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..f188f39 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -28,7 +28,7 @@ echo "Publishing plugins from $SOURCE_BRANCH to $RELEASES_BRANCH" # Create temporary working directory WORK_DIR=$(mktemp -d) -trap "rm -rf $WORK_DIR" EXIT +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" @@ -57,17 +57,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 +72,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 +85,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 releases manifest.json README.md if git diff --cached --quiet; then echo "No changes to commit." diff --git a/README.md b/README.md index 11d58f6..ed24c8b 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 @@ -86,9 +85,7 @@ curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest **3. Verify** ```bash -jq -r '.signature' manifest.json > manifest.json.sig -jq -c '.manifest' manifest.json | gpg --verify manifest.json.sig - -rm manifest.json.sig +jq -c '.manifest' manifest.json | gpg --verify <(jq -r '.signature' manifest.json) - ``` A successful result looks like: @@ -98,4 +95,4 @@ gpg: Signature made ... gpg: Good signature from "..." [full] ``` -The same steps apply to any per-plugin manifest — substitute the path to `metadata//manifest.json`. \ No newline at end of file +The same steps apply to any per-plugin manifest — substitute the path to `zips//manifest.json`. \ No newline at end of file From 5ad64fa15b341f8b0b4340902c73ad4426c38f3a Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:44:21 -0400 Subject: [PATCH 13/30] Migrate legacy release directories to new zips/ structure in cleanup script --- .github/scripts/publish/cleanup.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/scripts/publish/cleanup.sh b/.github/scripts/publish/cleanup.sh index 973af19..0a8aa2a 100644 --- a/.github/scripts/publish/cleanup.sh +++ b/.github/scripts/publish/cleanup.sh @@ -14,6 +14,28 @@ set -e : "${SOURCE_BRANCH:?}" MAX_VERSIONED_ZIPS=${MAX_VERSIONED_ZIPS:-10} +# ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ───── +# Migrates releases// → zips// (folder rename from old layout). +if [[ -d releases ]]; then + echo " Migrating legacy releases/ → zips/" + mkdir -p zips + for old_dir in releases/*/; do + [[ ! -d "$old_dir" ]] && continue + plugin_name=$(basename "$old_dir") + mkdir -p "zips/$plugin_name" + # Move any files not already present in the destination + for f in "$old_dir"*; do + [[ -f "$f" ]] || continue + dest="zips/$plugin_name/$(basename "$f")" + if [[ ! -f "$dest" ]]; then + mv "$f" "$dest" + fi + done + done + rm -rf releases +fi +# ── END LEGACY CLEANUP ────────────────────────────────────────────────────────── + # ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ───── # Removes the old metadata/ directory that pre-dated zips//manifest.json. if [[ -d metadata ]]; then From 92ed1c40da21cc1c414a7e262bc4ad04b0dfce26 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:45:29 -0400 Subject: [PATCH 14/30] Update publish script to add zips directory instead of releases for commit --- .github/scripts/publish/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index f188f39..6740d3b 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -85,7 +85,7 @@ echo "" echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true -git add releases manifest.json README.md +git add zips manifest.json README.md if git diff --cached --quiet; then echo "No changes to commit." From 31ce0e934200c7e90f5c6b3e9ba57ff362e8cdb6 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:47:26 -0400 Subject: [PATCH 15/30] Stage deletions of legacy directories in publish script --- .github/scripts/publish/run.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index 6740d3b..e9bffb7 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -85,6 +85,8 @@ echo "" echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true +# Stage deletions of legacy directories that cleanup.sh may have removed +git rm -rf --cached releases metadata 2>/dev/null || true git add zips manifest.json README.md if git diff --cached --quiet; then From a47b3fb4e4211fa909fcd5be3aa26fffe1c1ad9a Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:50:58 -0400 Subject: [PATCH 16/30] Remove legacy cleanup code from scripts and update README for consistency --- .github/scripts/publish/cleanup.sh | 43 +------------------- .github/scripts/publish/generate-manifest.sh | 2 +- .github/scripts/publish/run.sh | 10 +++-- .github/workflows/publish-plugins.yml | 7 ++++ README.md | 2 +- 5 files changed, 17 insertions(+), 47 deletions(-) diff --git a/.github/scripts/publish/cleanup.sh b/.github/scripts/publish/cleanup.sh index 0a8aa2a..3010e8f 100644 --- a/.github/scripts/publish/cleanup.sh +++ b/.github/scripts/publish/cleanup.sh @@ -4,8 +4,6 @@ set -e # publish-cleanup.sh # Removes release artifacts for plugins that no longer exist in source, # and prunes versioned ZIPs beyond MAX_VERSIONED_ZIPS. -# Also contains LEGACY CLEANUP blocks (clearly marked) that can be removed -# once all pre-existing releases branches have been migrated. # # Called from the releases branch checkout directory by publish-plugins.sh. # Required env: SOURCE_BRANCH @@ -14,36 +12,6 @@ set -e : "${SOURCE_BRANCH:?}" MAX_VERSIONED_ZIPS=${MAX_VERSIONED_ZIPS:-10} -# ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ───── -# Migrates releases// → zips// (folder rename from old layout). -if [[ -d releases ]]; then - echo " Migrating legacy releases/ → zips/" - mkdir -p zips - for old_dir in releases/*/; do - [[ ! -d "$old_dir" ]] && continue - plugin_name=$(basename "$old_dir") - mkdir -p "zips/$plugin_name" - # Move any files not already present in the destination - for f in "$old_dir"*; do - [[ -f "$f" ]] || continue - dest="zips/$plugin_name/$(basename "$f")" - if [[ ! -f "$dest" ]]; then - mv "$f" "$dest" - fi - done - done - rm -rf releases -fi -# ── END LEGACY CLEANUP ────────────────────────────────────────────────────────── - -# ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ───── -# Removes the old metadata/ directory that pre-dated zips//manifest.json. -if [[ -d metadata ]]; then - echo " Removing legacy metadata/ directory" - rm -rf metadata -fi -# ── END LEGACY CLEANUP ────────────────────────────────────────────────────────── - # Remove artifacts for deleted plugins if [[ -d zips ]]; then for release_dir in zips/*/; do @@ -56,7 +24,7 @@ if [[ -d zips ]]; then done fi -# Prune old versions and remove legacy per-version JSON files per plugin +# Prune old versions per plugin for plugin_dir in plugins/*/; do [[ ! -d "$plugin_dir" ]] && continue plugin_name=$(basename "$plugin_dir") @@ -69,13 +37,4 @@ for plugin_dir in plugins/*/; do done < <(ls -1t "$zip_dir/${plugin_name}-"*.zip 2>/dev/null \ | grep -v "${plugin_name}-latest.zip" \ | awk "NR>$MAX_VERSIONED_ZIPS") - - # ── LEGACY CLEANUP (safe to remove once all releases branches are migrated) ─── - # Removes per-version -.json files that pre-dated embedded manifest versions. - for legacy_file in "$zip_dir/${plugin_name}-"*.json; do - [[ -f "$legacy_file" ]] || continue - echo " Removed legacy metadata: $(basename "$legacy_file")" - rm -f "$legacy_file" - done - # ── END LEGACY CLEANUP ──────────────────────────────────────────────────────── done diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 524430f..4e22d95 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -118,7 +118,7 @@ for plugin_dir in plugins/*/; do versioned_zips="[]" latest_metadata="{}" - # existing per-plugin manifest from previous run — used as metadata fallback + # 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 diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index e9bffb7..59a6473 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -40,7 +40,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 @@ -85,8 +91,6 @@ echo "" echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true -# Stage deletions of legacy directories that cleanup.sh may have removed -git rm -rf --cached releases metadata 2>/dev/null || true git add zips manifest.json README.md if git diff --cached --quiet; then diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 8064349..7982f40 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -21,6 +21,12 @@ on: - '.github/scripts/publish/releases-readme.sh' - '.github/scripts/keys/**' workflow_dispatch: + inputs: + force_rebuild: + description: 'Delete and recreate the releases branch from scratch' + type: boolean + default: false + required: false concurrency: group: publish-plugins @@ -46,6 +52,7 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + FORCE_REBUILD: ${{ inputs.force_rebuild }} run: | set -o pipefail .github/scripts/publish/run.sh ${{ github.ref_name }} 2>&1 | tee publish.log diff --git a/README.md b/README.md index ed24c8b..8bd608f 100644 --- a/README.md +++ b/README.md @@ -95,4 +95,4 @@ 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 +The same steps apply to any per-plugin manifest - substitute the path to `zips//manifest.json`. \ No newline at end of file From bbb165dea694dc70dd1b0cbf440e46c7d02dbd63 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:53:57 -0400 Subject: [PATCH 17/30] Refactor build and readme scripts to improve metadata handling and ensure temporary directories are properly managed --- .github/scripts/publish/build-zips.sh | 6 +--- .github/scripts/publish/plugin-readmes.sh | 42 +++++++++++++---------- .github/scripts/publish/run.sh | 6 ++-- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/.github/scripts/publish/build-zips.sh b/.github/scripts/publish/build-zips.sh index bfcbc26..28d7de2 100644 --- a/.github/scripts/publish/build-zips.sh +++ b/.github/scripts/publish/build-zips.sh @@ -13,11 +13,7 @@ set -e # 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:?}" - -# Temp dir for this run's per-version metadata, cleaned up by run.sh trap -export BUILD_META_DIR -BUILD_META_DIR=$(mktemp -d) +: "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" "${BUILD_META_DIR:?}" > changed_plugins.txt diff --git a/.github/scripts/publish/plugin-readmes.sh b/.github/scripts/publish/plugin-readmes.sh index 89566c7..f32ddc2 100644 --- a/.github/scripts/publish/plugin-readmes.sh +++ b/.github/scripts/publish/plugin-readmes.sh @@ -67,21 +67,23 @@ for plugin_dir in plugins/*/; do '.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') - commit_sha_short=$(echo "$meta_entry" | jq -r '.commit_sha_short') - build_timestamp=$(echo "$meta_entry" | jq -r '.build_timestamp') - checksum_md5=$(echo "$meta_entry" | jq -r '.checksum_md5') - checksum_sha256=$(echo "$meta_entry" | jq -r '.checksum_sha256') + 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)" - 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 "\`\`\`" + [[ -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/zips/${plugin_name}/${plugin_name}-latest.zip)" fi @@ -106,13 +108,15 @@ for plugin_dir in plugins/*/; do fi if [[ -n "$meta_entry" ]]; then - commit_sha_short=$(echo "$meta_entry" | jq -r '.commit_sha_short') - commit_sha=$(echo "$meta_entry" | jq -r '.commit_sha') - build_timestamp=$(echo "$meta_entry" | jq -r '.build_timestamp') - checksum_md5=$(echo "$meta_entry" | jq -r '.checksum_md5') - checksum_sha256=$(echo "$meta_entry" | jq -r '.checksum_sha256') + 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/zips/${plugin_name}/${zip_basename}) | $build_date | [\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha}) | \`$checksum_md5\` | \`$checksum_sha256\` |" + 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/zips/${plugin_name}/${zip_basename}) | - | - | - |" fi diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index 59a6473..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" "${BUILD_META_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" From f8b78689e1d5ee97c080376f652037b666bd22c7 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:55:57 -0400 Subject: [PATCH 18/30] test plugin version bump --- plugins/dispatcharr-exporter/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/dispatcharr-exporter/plugin.json b/plugins/dispatcharr-exporter/plugin.json index 9f05318..b83634e 100644 --- a/plugins/dispatcharr-exporter/plugin.json +++ b/plugins/dispatcharr-exporter/plugin.json @@ -1,6 +1,6 @@ { "name": "Dispatcharr Exporter", - "version": "2.4.0", + "version": "2.4.1", "description": "Expose Dispatcharr metrics in Prometheus exporter-compatible format for monitoring", "license": "MIT", "discord_thread": "https://discord.com/channels/1340492560220684331/1451260201775923421", From 6063aba3886eba7c74f6dffc678f1a1931a55cf6 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 16:59:41 -0400 Subject: [PATCH 19/30] Remove unused metadata fields from plugin manifest generation --- .github/scripts/publish/generate-manifest.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 4e22d95..9d22c11 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -176,12 +176,7 @@ 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") From b3bc9260a946420a77b3e1fd5139610a023eb135 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:01:29 -0400 Subject: [PATCH 20/30] Remove unused latest_url field from plugin manifest generation --- .github/scripts/publish/generate-manifest.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 9d22c11..dc000a5 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -167,7 +167,6 @@ for plugin_dir in plugins/*/; do "deprecated","unlisted","min_dispatcharr_version","max_dispatcharr_version","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) + ( @@ -208,6 +207,7 @@ for plugin_dir in plugins/*/; do --arg license "$(jq -r '.license // ""' "$plugin_file")" \ --arg latest_url "$latest_url" \ '{ + slug: $plugin_name, name: $name, description: $description, icon_url: (if $icon_url != "" then $icon_url else null end), From a5775bae23610521cd39b9cbae962fe351a7bc8a Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:02:17 -0400 Subject: [PATCH 21/30] Fix slug assignment in root entry generation for plugin manifest --- .github/scripts/publish/generate-manifest.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index dc000a5..f34ab94 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -199,6 +199,7 @@ for plugin_dir in plugins/*/; do root_entry=$(jq -n \ --argjson latest_metadata "$latest_metadata" \ + --arg slug "$plugin_name" \ --arg name "$(jq -r '.name // ""' "$plugin_file")" \ --arg description "$desc_trimmed" \ --arg icon_url "$icon_url" \ @@ -207,7 +208,7 @@ for plugin_dir in plugins/*/; do --arg license "$(jq -r '.license // ""' "$plugin_file")" \ --arg latest_url "$latest_url" \ '{ - slug: $plugin_name, + slug: $slug, name: $name, description: $description, icon_url: (if $icon_url != "" then $icon_url else null end), From 72c467c3276d947e2ca61fdd643c19c442432ff3 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:05:12 -0400 Subject: [PATCH 22/30] Skip unlisted plugins in manifest generation and refine included fields --- .github/scripts/publish/generate-manifest.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index f34ab94..3d3bdd0 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -110,6 +110,7 @@ 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" @@ -163,8 +164,8 @@ 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, versions: $versioned_zips From f295319d9e5822ef498e5c0eb70e7dc101b05446 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:07:31 -0400 Subject: [PATCH 23/30] Clarify `unlisted` field description in `plugin.json` and update checksum computation details in CONTRIBUTING.md --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 2b0feefae5779b5e2445085c9bbe5c5d2f400691 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:09:19 -0400 Subject: [PATCH 24/30] test another version bump for testing publish script changes --- plugins/dispatcharr-exporter/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/dispatcharr-exporter/plugin.json b/plugins/dispatcharr-exporter/plugin.json index b83634e..374f22a 100644 --- a/plugins/dispatcharr-exporter/plugin.json +++ b/plugins/dispatcharr-exporter/plugin.json @@ -1,6 +1,6 @@ { "name": "Dispatcharr Exporter", - "version": "2.4.1", + "version": "2.4.2", "description": "Expose Dispatcharr metrics in Prometheus exporter-compatible format for monitoring", "license": "MIT", "discord_thread": "https://discord.com/channels/1340492560220684331/1451260201775923421", From fcf1008c8fdaa62a7bfd7943e355c7ee51a1f41f Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:10:51 -0400 Subject: [PATCH 25/30] Add unlisted field to plugin.json for Dispatcharr Exporter --- plugins/dispatcharr-exporter/plugin.json | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/dispatcharr-exporter/plugin.json b/plugins/dispatcharr-exporter/plugin.json index 374f22a..d3ce3c1 100644 --- a/plugins/dispatcharr-exporter/plugin.json +++ b/plugins/dispatcharr-exporter/plugin.json @@ -1,6 +1,7 @@ { "name": "Dispatcharr Exporter", "version": "2.4.2", + "unlisted": true, "description": "Expose Dispatcharr metrics in Prometheus exporter-compatible format for monitoring", "license": "MIT", "discord_thread": "https://discord.com/channels/1340492560220684331/1451260201775923421", From c79355af32dc7a01173e9f999ee41972f3f4ccdd Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:11:50 -0400 Subject: [PATCH 26/30] Revert version to 2.4.0 and remove unlisted field from plugin.json --- plugins/dispatcharr-exporter/plugin.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/dispatcharr-exporter/plugin.json b/plugins/dispatcharr-exporter/plugin.json index d3ce3c1..9f05318 100644 --- a/plugins/dispatcharr-exporter/plugin.json +++ b/plugins/dispatcharr-exporter/plugin.json @@ -1,7 +1,6 @@ { "name": "Dispatcharr Exporter", - "version": "2.4.2", - "unlisted": true, + "version": "2.4.0", "description": "Expose Dispatcharr metrics in Prometheus exporter-compatible format for monitoring", "license": "MIT", "discord_thread": "https://discord.com/channels/1340492560220684331/1451260201775923421", From 6b04178375f628f924a328e944eed5fffcf9c9bf Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sat, 21 Mar 2026 17:13:28 -0400 Subject: [PATCH 27/30] Add versioned_zips to plugin manifest generation --- .github/scripts/publish/generate-manifest.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index 3d3bdd0..c752371 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -200,6 +200,7 @@ for plugin_dir in plugins/*/; do 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" \ @@ -207,7 +208,6 @@ for plugin_dir in plugins/*/; do --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, @@ -219,7 +219,7 @@ for plugin_dir in plugins/*/; do latest_version: ($latest_metadata.version // null), latest_md5: ($latest_metadata.checksum_md5 // null), latest_sha256: ($latest_metadata.checksum_sha256 // null), - latest_url: $latest_url, + 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))') From aebdae89cf0e36536bba4bfacbc319a4c881adea Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 22 Mar 2026 13:06:54 -0400 Subject: [PATCH 28/30] Remove test PGP public key file --- .github/scripts/keys/dispatcharr-plugins.pub | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/scripts/keys/dispatcharr-plugins.pub diff --git a/.github/scripts/keys/dispatcharr-plugins.pub b/.github/scripts/keys/dispatcharr-plugins.pub deleted file mode 100644 index a3eef51..0000000 --- a/.github/scripts/keys/dispatcharr-plugins.pub +++ /dev/null @@ -1,11 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mDMEab7yvRYJKwYBBAHaRw8BAQdAkU2oCAuiS3mAh0JZJKqRZsjp/3LsCCzfAnMF -23sXvx+0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv -Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBIgZ -2Ccsw7kHuv/iwfiYcskfxgqNBQJpvvK9GxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy -LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRD4mHLJH8YKjTe4 -AP9Q6DmkNefZN19/U7u9bY7OizMTPJMmT9aGFPldTc8HmQD+IDXC3nJ7Rd7pSZ1b -wjPGuCd84DxLzMePnfcIy2OTxQg= -=2pYw ------END PGP PUBLIC KEY BLOCK----- From 13228545ace334464e5ca7e3a1d4c5c0b4903c3b Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 22 Mar 2026 13:09:49 -0400 Subject: [PATCH 29/30] Enhance force rebuild functionality in publish workflow with confirmation input --- .github/workflows/publish-plugins.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 7982f40..9fdaea7 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -23,10 +23,15 @@ on: workflow_dispatch: inputs: force_rebuild: - description: 'Delete and recreate the releases branch from scratch' + 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 @@ -53,8 +58,13 @@ jobs: 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 From 13bc07f5b0698cfd6ec5e9e9b112a968eec5769d Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 22 Mar 2026 13:12:50 -0400 Subject: [PATCH 30/30] Improve handling of GPG signing failures by clarifying conditions for stripping signatures from manifests --- .github/scripts/publish/generate-manifest.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh index c752371..db06dc5 100644 --- a/.github/scripts/publish/generate-manifest.sh +++ b/.github/scripts/publish/generate-manifest.sh @@ -247,10 +247,12 @@ elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current " sign_manifest "manifest.json" fi -# If any signing step failed, strip embedded signatures from all manifests so -# the repo is never left in a partially-signed state. -if [[ "$gpg_signing_failed" -eq 1 ]]; then - echo "::warning::Removing all signatures due to signing failure." +# 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"