diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c3d958d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ + + +## About this submission + + + +## Pre-submission checklist + + + +**If this is a new plugin:** +- [ ] Plugin folder is named `lowercase-kebab-case` +- [ ] `plugin.json` contains all required fields (`name`, `version`, `description`, `author` or `maintainers`, `license`) +- [ ] My GitHub username is in `author` or `maintainers` +- [ ] `license` is a valid [OSI-approved SPDX identifier](https://spdx.org/licenses/) (e.g. `MIT`, `Apache-2.0`) +- [ ] I have tested the plugin against a running Dispatcharr instance + +**If this is an update to an existing plugin:** +- [ ] `version` in `plugin.json` is incremented (unless this is a metadata-only change - see [Versioning](https://github.com/Dispatcharr/Plugins/blob/main/CONTRIBUTING.md#versioning)) +- [ ] I am listed in `author` or `maintainers` of the existing plugin diff --git a/.github/scripts/publish/plugin-readmes.sh b/.github/scripts/publish/plugin-readmes.sh index f32ddc2..c66f2c7 100644 --- a/.github/scripts/publish/plugin-readmes.sh +++ b/.github/scripts/publish/plugin-readmes.sh @@ -24,6 +24,8 @@ for plugin_dir in plugins/*/; do repo_url=$(jq -r '.repo_url // empty' "$plugin_file") discord_thread=$(jq -r '.discord_thread // empty' "$plugin_file") license=$(jq -r '.license // ""' "$plugin_file") + min_dispatcharr=$(jq -r '.min_dispatcharr_version // empty' "$plugin_file") + max_dispatcharr=$(jq -r '.max_dispatcharr_version // empty' "$plugin_file") has_readme=false [[ -f "$plugin_dir/README.md" ]] && has_readme=true @@ -48,6 +50,18 @@ for plugin_dir in plugins/*/; do echo "**License:** [$license](https://spdx.org/licenses/${license}.html)" echo "" fi + if [[ -n "$min_dispatcharr" || -n "$max_dispatcharr" ]]; then + compat="" + if [[ -n "$min_dispatcharr" && -n "$max_dispatcharr" ]]; then + compat="$min_dispatcharr – $max_dispatcharr" + elif [[ -n "$min_dispatcharr" ]]; then + compat="$min_dispatcharr+" + else + compat="up to $max_dispatcharr" + fi + echo "**Dispatcharr Compatibility:** $compat" + echo "" + fi echo "## Downloads" echo "" echo "### Latest Release" diff --git a/.github/scripts/publish/releases-readme.sh b/.github/scripts/publish/releases-readme.sh index 98de7f9..9eee9f6 100644 --- a/.github/scripts/publish/releases-readme.sh +++ b/.github/scripts/publish/releases-readme.sh @@ -26,6 +26,8 @@ render_plugin() { local commit_sha_short=${10} local version_count=${11} local license=${12} + local min_dispatcharr=${13} + local max_dispatcharr=${14} 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}" @@ -49,8 +51,20 @@ render_plugin() { echo "**License:** [$license](https://spdx.org/licenses/${license}.html)" echo "" fi + if [[ -n "$min_dispatcharr" || -n "$max_dispatcharr" ]]; then + compat="" + if [[ -n "$min_dispatcharr" && -n "$max_dispatcharr" ]]; then + compat="$min_dispatcharr – $max_dispatcharr" + elif [[ -n "$min_dispatcharr" ]]; then + compat="$min_dispatcharr+" + else + compat="up to $max_dispatcharr" + fi + echo "**Dispatcharr Compatibility:** $compat" + echo "" + fi echo "**Downloads:**" - echo "- [Latest Release (\`$version\`)]($zip_url)" + echo " [Latest Release (\`$version\`)]($zip_url)" echo "- [All Versions ($version_count available)]($releases_dir)" echo "" @@ -132,9 +146,12 @@ render_plugin() { 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") + min_dispatcharr=$(jq -r '.min_dispatcharr_version // empty' "$plugin_file") + max_dispatcharr=$(jq -r '.max_dispatcharr_version // empty' "$plugin_file") render_plugin "false" "$plugin_name" "$name" "$version" "$author" "$description" \ - "$maintainers" "$last_updated" "$commit_sha" "$commit_sha_short" "$version_count" "$plugin_license" + "$maintainers" "$last_updated" "$commit_sha" "$commit_sha_short" "$version_count" "$plugin_license" \ + "$min_dispatcharr" "$max_dispatcharr" done # Deprecated section (only if any exist) @@ -176,9 +193,12 @@ render_plugin() { 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") + min_dispatcharr=$(jq -r '.min_dispatcharr_version // empty' "$plugin_file") + max_dispatcharr=$(jq -r '.max_dispatcharr_version // empty' "$plugin_file") render_plugin "true" "$plugin_name" "$name" "$version" "$author" "$description" \ - "$maintainers" "$last_updated" "$commit_sha" "$commit_sha_short" "$version_count" "$plugin_license" + "$maintainers" "$last_updated" "$commit_sha" "$commit_sha_short" "$version_count" "$plugin_license" \ + "$min_dispatcharr" "$max_dispatcharr" done fi diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh index d4d0d47..780fe41 100644 --- a/.github/scripts/publish/run.sh +++ b/.github/scripts/publish/run.sh @@ -36,9 +36,23 @@ echo "Cloning repository..." git clone --no-checkout "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$WORK_DIR/repo" cd "$WORK_DIR/repo" -# Configure git -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" +# Configure git - use GitHub App bot identity when available, otherwise fall back +# to the generic github-actions[bot] identity. +# NOTE: the email uses the bot *user* ID (not the App ID) - GitHub resolves commit +# authorship by matching this ID+slug[bot]@users.noreply.github.com to the bot account. +if [[ -n "${APP_SLUG:-}" ]]; then + BOT_USER_ID=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq '.id' 2>/dev/null || echo "") + git config user.name "${APP_SLUG}[bot]" + if [[ -n "$BOT_USER_ID" ]]; then + git config user.email "${BOT_USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com" + else + # Fallback if the API call fails - avatar may not resolve but commits still work + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + fi +else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +fi # Checkout or create releases branch echo "Setting up $RELEASES_BRANCH branch..." @@ -93,24 +107,52 @@ echo "" echo "=== Committing ===" rm -rf plugins git rm -rf --cached plugins 2>/dev/null || true + git add zips manifest.json README.md if git diff --cached --quiet; then echo "No changes to commit." else - source_commit=$(git rev-parse --short origin/$SOURCE_BRANCH) - - plugin_list="" - if [[ -s changed_plugins.txt ]]; then - plugin_list="$(printf '\n\n')$(sed 's/^/- /' changed_plugins.txt)" - fi - - git commit -m "Publish plugin updates from $SOURCE_BRANCH + # Check whether the staged diff is purely timestamp noise: + # README.md - "*Last updated: ..." footer + # manifest.json - "generated_at" field + # Any other changed file (e.g. a ZIP) counts as a real change. + only_timestamps=true + while IFS= read -r changed_file; do + case "$changed_file" in + README.md) + new_content=$(git show :README.md | grep -v '^\*Last updated:') + old_content=$(git show HEAD:README.md 2>/dev/null | grep -v '^\*Last updated:' || true) + [[ "$new_content" == "$old_content" ]] || only_timestamps=false + ;; + manifest.json) + new_content=$(git show :manifest.json | grep -v '"generated_at"') + old_content=$(git show HEAD:manifest.json 2>/dev/null | grep -v '"generated_at"' || true) + [[ "$new_content" == "$old_content" ]] || only_timestamps=false + ;; + *) + only_timestamps=false + ;; + esac + $only_timestamps || break + done < <(git diff --cached --name-only) + + if $only_timestamps; then + echo "No meaningful changes (only timestamps updated) - skipping commit." + else + source_commit=$(git rev-parse --short origin/$SOURCE_BRANCH) + plugin_list="" + if [[ -s changed_plugins.txt ]]; then + plugin_list="$(printf '\n\n')$(sed 's/^/- /' changed_plugins.txt)" + fi + + git commit -m "Publish plugin updates from $SOURCE_BRANCH Source commit: $source_commit${plugin_list} [skip ci]" - git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" $RELEASES_BRANCH - echo "Successfully published to $RELEASES_BRANCH" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" $RELEASES_BRANCH + echo "Successfully published to ${RELEASES_BRANCH}" + fi # end only_timestamps check fi diff --git a/.github/scripts/validate/report.sh b/.github/scripts/validate/report.sh index 6f0d417..4aaac27 100755 --- a/.github/scripts/validate/report.sh +++ b/.github/scripts/validate/report.sh @@ -197,6 +197,38 @@ done fi } > pr_comment.txt +# Minimize all previous validation comments as outdated before posting the new one +OWNER="${GITHUB_REPOSITORY%%/*}" +REPO="${GITHUB_REPOSITORY##*/}" + +PREV_NODE_IDS=$(gh api graphql -f query=' + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + comments(first: 100) { + nodes { id body } + } + } + } + } +' -f owner="$OWNER" -f repo="$REPO" -F number="$PR_NUMBER" \ + --jq '.data.repository.pullRequest.comments.nodes[] + | select(.body | contains("")) + | .id' 2>/dev/null || true) + +if [[ -n "$PREV_NODE_IDS" ]]; then + while IFS= read -r node_id; do + gh api graphql -f query=' + mutation($id: ID!) { + minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { + minimizedComment { isMinimized } + } + } + ' -f id="$node_id" > /dev/null 2>&1 || true + done <<< "$PREV_NODE_IDS" + echo "Minimized $(echo "$PREV_NODE_IDS" | wc -l | tr -d ' ') previous validation comment(s) as outdated" +fi + # Post PR comment - script succeeds/fails based on whether the comment posted gh pr comment "$PR_NUMBER" --body "$(cat pr_comment.txt)" COMMENT_EXIT=$? diff --git a/.github/scripts/validate/validate.sh b/.github/scripts/validate/validate.sh index fd1c564..7e9c900 100755 --- a/.github/scripts/validate/validate.sh +++ b/.github/scripts/validate/validate.sh @@ -213,13 +213,46 @@ has_permission="false" fi # Version bump + # These fields may be updated without a version bump - they are metadata only + # and do not affect the packaged ZIP artifact. + METADATA_ONLY_FIELDS=("description" "repo_url" "discord_thread" + "min_dispatcharr_version" "max_dispatcharr_version" "deprecated" "unlisted" "maintainers") + if git show "origin/${BASE_REF}:${PLUGIN_JSON}" > /dev/null 2>&1; then OLD_VERSION=$(git show "origin/${BASE_REF}:${PLUGIN_JSON}" | jq -r '.version') if version_greater_than "$VERSION" "$OLD_VERSION"; then TABLE_ROWS+=("| Version bump | ✅ | \`$OLD_VERSION\` → \`$VERSION\` |") else - TABLE_ROWS+=("| Version bump | ❌ | \`$VERSION\` must be greater than current \`$OLD_VERSION\` |") - failed=1 + # Version unchanged - check if every changed field is in the metadata-only allowlist + OLD_JSON=$(git show "origin/${BASE_REF}:${PLUGIN_JSON}") + NEW_JSON=$(cat "$PLUGIN_JSON") + + # Produce a newline-separated list of field names that differ (raw strings, no quotes) + changed_fields=$(jq -rn \ + --argjson old "$OLD_JSON" \ + --argjson new "$NEW_JSON" \ + '[$new | keys[]] | map(select($old[.] != $new[.])) | .[]' 2>/dev/null || true) + + metadata_only_change=true + while IFS= read -r field; do + [[ -z "$field" ]] && continue + allowed=false + for mf in "${METADATA_ONLY_FIELDS[@]}"; do + [[ "$field" == "$mf" ]] && allowed=true && break + done + $allowed || { metadata_only_change=false; break; } + done <<< "$changed_fields" + + if $metadata_only_change && [[ -n "$changed_fields" ]]; then + TABLE_ROWS+=("| Version bump | ✅ | \`$OLD_VERSION\` (unchanged - metadata-only update) |") + elif $metadata_only_change && [[ -z "$changed_fields" ]]; then + # Nothing changed at all - still require a bump so PRs aren't no-ops + TABLE_ROWS+=("| Version bump | ❌ | No changes detected - nothing to publish |") + failed=1 + else + TABLE_ROWS+=("| Version bump | ❌ | \`$VERSION\` must be greater than current \`$OLD_VERSION\` |") + failed=1 + fi fi else TABLE_ROWS+=("| Version bump | ✅ | New plugin |") diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 67f36a9..661e3e1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,10 +4,10 @@ name: CodeQL # which runs CodeQL as part of the plugin validation flow. on: workflow_dispatch: - push: - branches: [main] - schedule: - - cron: '0 6 * * 1' + # push: + # branches: [main] + # schedule: + # - cron: '0 6 * * 1' jobs: analyze: diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index 9fdaea7..2669cbb 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -23,7 +23,7 @@ on: workflow_dispatch: inputs: force_rebuild: - description: 'Wipe and recreate the releases branch from scratch. WARNING: purges all version history — only the latest version of each plugin will be retained.' + 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 @@ -47,13 +47,40 @@ jobs: with: fetch-depth: 0 + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Make scripts executable run: chmod +x .github/scripts/publish/*.sh - name: Publish plugins to releases branch id: publish env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} GITHUB_REPOSITORY: ${{ github.repository }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.github/workflows/update-external-readme.yml b/.github/workflows/update-external-readme.yml new file mode 100644 index 0000000..a39ef9f --- /dev/null +++ b/.github/workflows/update-external-readme.yml @@ -0,0 +1,206 @@ +name: Publish Plugin Docs PR + +# Reads the README.md generated by the Publish Plugins workflow from the +# releases branch and opens (or updates) a PR in a target repository. + +on: + workflow_run: + workflows: ["Publish Plugins"] + types: + - completed + branches: + - main + workflow_dispatch: + +jobs: + update-readme: + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Load and validate configuration + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + EXTERNAL_README_REPO: ${{ vars.EXTERNAL_README_REPO }} + EXTERNAL_README_PATH: ${{ vars.EXTERNAL_README_PATH }} + run: | + if [[ -z "${GH_APP_ID:-}" || -z "${GH_APP_PRIVATE_KEY:-}" || -z "${EXTERNAL_README_REPO:-}" || -z "${EXTERNAL_README_PATH:-}" ]]; then + echo "::notice::GH_APP_ID, GH_APP_PRIVATE_KEY, EXTERNAL_README_REPO, or EXTERNAL_README_PATH not configured - skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "app_id=$GH_APP_ID" >> "$GITHUB_OUTPUT" + echo "target_repo=$EXTERNAL_README_REPO" >> "$GITHUB_OUTPUT" + echo "target_path=$EXTERNAL_README_PATH" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.skip != 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + # Token is org-scoped; the app must be installed on the target repo + owner: ${{ github.repository_owner }} + + - name: Checkout releases branch README + if: steps.config.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + ref: releases + fetch-depth: 1 + clean: false + sparse-checkout: README.md + sparse-checkout-cone-mode: false + + - name: Extract release metadata from commit + if: steps.config.outputs.skip != 'true' + id: meta + run: | + COMMIT_MSG=$(git log -1 --format="%B" HEAD) + + # Extract source commit SHA from "Source commit: " line + SOURCE_COMMIT=$(echo "$COMMIT_MSG" | grep -oP '(?<=Source commit: )[0-9a-f]+' || true) + + # Extract plugin list - lines starting with "- " after the source commit line + PLUGIN_LIST=$(echo "$COMMIT_MSG" | grep -E '^\- [a-z0-9].*@' || true) + + RELEASES_COMMIT=$(git rev-parse --short HEAD) + + echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT" + echo "releases_commit=${RELEASES_COMMIT}" >> "$GITHUB_OUTPUT" + + # Write plugin list to a file to avoid quoting issues in multi-line output + printf '%s' "$PLUGIN_LIST" > /tmp/plugin_list.txt + + - name: Strip releases-branch preamble from README + if: steps.config.outputs.skip != 'true' + env: + SOURCE_REPO: ${{ github.repository }} + run: | + # Remove the intro paragraph and Quick Access section that are only + # relevant on the releases branch itself, keeping everything from + # "## Available Plugins" onward (preserving the "# Plugin Releases" title). + CONTRIB_URL="https://github.com/${SOURCE_REPO}/blob/main/CONTRIBUTING.md" + awk -v contrib="$CONTRIB_URL" ' + /^# Plugin Releases/ { print; print ""; print "Want to get your plugin added to this list? Check out the [plugin repository](" contrib ") to learn how to contribute."; next } + /^## Available Plugins/ { found=1 } + found { print } + ' README.md > README.stripped.md + mv README.stripped.md README.md + + # Rewrite relative links (./zips/...) to absolute GitHub URLs pointing + # at the releases branch. Anchor links (#...) are unaffected because + # they never start with "./". + BASE_URL="https://github.com/${SOURCE_REPO}/tree/releases" + sed -i "s|](\./|](${BASE_URL}/|g" README.md + + - name: Create or update PR in target repo + if: steps.config.outputs.skip != 'true' + env: + TARGET_REPO: ${{ steps.config.outputs.target_repo }} + TARGET_PATH: ${{ steps.config.outputs.target_path }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_REPO: ${{ github.repository }} + SOURCE_COMMIT: ${{ steps.meta.outputs.source_commit }} + RELEASES_COMMIT: ${{ steps.meta.outputs.releases_commit }} + PR_REVIEWER: ${{ vars.PR_REVIEWER }} + run: | + if [[ ! -f README.md ]]; then + echo "::error::README.md not found on the releases branch - has the Publish Plugins workflow run yet?" + exit 1 + fi + + PLUGIN_LIST=$(cat /tmp/plugin_list.txt) + + # Build a human-readable change summary block + build_summary() { + echo "**Source commit:** [\`${SOURCE_COMMIT}\`](https://github.com/${SOURCE_REPO}/commit/${SOURCE_COMMIT})" + echo "**Releases commit:** [\`${RELEASES_COMMIT}\`](https://github.com/${SOURCE_REPO}/commit/${RELEASES_COMMIT})" + if [[ -n "$PLUGIN_LIST" ]]; then + echo "" + echo "**Plugins updated:**" + while IFS= read -r line; do + echo "$line" + done <<< "$PLUGIN_LIST" + fi + } + + BRANCH="auto/dispatcharr-plugin-readme" + DEFAULT_BRANCH=$(gh api "repos/$TARGET_REPO" --jq '.default_branch') + + # Create branch if it does not already exist + if ! gh api "repos/$TARGET_REPO/branches/$BRANCH" > /dev/null 2>&1; then + BASE_SHA=$(gh api "repos/$TARGET_REPO/git/refs/heads/$DEFAULT_BRANCH" \ + --jq '.object.sha') + gh api "repos/$TARGET_REPO/git/refs" \ + -X POST \ + -f ref="refs/heads/$BRANCH" \ + -f sha="$BASE_SHA" + echo "Created branch $BRANCH" + fi + + # Get the existing file SHA and content from the branch + EXISTING_RESPONSE=$(gh api "repos/$TARGET_REPO/contents/$TARGET_PATH?ref=$BRANCH" 2>/dev/null || true) + FILE_SHA=$(echo "$EXISTING_RESPONSE" | jq -r '.sha // empty') + + # Skip if the only change is the timestamp line at the bottom of the README + if [[ -n "$FILE_SHA" ]]; then + EXISTING_CONTENT=$(echo "$EXISTING_RESPONSE" | jq -r '.content' | base64 -d 2>/dev/null || true) + NEW_STRIPPED=$(grep -v '^\*Last updated:' README.md || true) + EXISTING_STRIPPED=$(echo "$EXISTING_CONTENT" | grep -v '^\*Last updated:' || true) + if [[ "$NEW_STRIPPED" == "$EXISTING_STRIPPED" ]]; then + echo "README content unchanged (only timestamp differs) - skipping." + echo "::notice::External README not updated: only the timestamp line changed." + exit 0 + fi + fi + + # Upload file (base64-encoded content is required by the GitHub Contents API) + PUT_ARGS=( + -X PUT + -f message="chore: update plugin releases listing (source commit $SOURCE_COMMIT)" + -f content="$(base64 -w 0 README.md)" + -f branch="$BRANCH" + ) + [[ -n "$FILE_SHA" ]] && PUT_ARGS+=(-f sha="$FILE_SHA") + + gh api "repos/$TARGET_REPO/contents/$TARGET_PATH" "${PUT_ARGS[@]}" + + # Re-use an existing open PR for this branch; open a new one if none exists + EXISTING_PR=$(gh pr list \ + --repo "$TARGET_REPO" \ + --state open \ + --head "$BRANCH" \ + --json number \ + | jq -r '.[0].number // empty') + + if [[ -n "$EXISTING_PR" ]]; then + # Add a comment to the existing PR summarising this update + COMMENT=$(printf '### Plugin README update\n\n%s' "$(build_summary)") + gh pr comment "$EXISTING_PR" --repo "$TARGET_REPO" --body "$COMMENT" + echo "Added update comment to existing PR #$EXISTING_PR" + else + PR_BODY=$(printf 'Automated update of the plugin releases listings generated from [`%s`](https://github.com/%s).\n\n%s' \ + "$SOURCE_REPO" "$SOURCE_REPO" "$(build_summary)") + REVIEWER_ARGS=() + if [[ -n "${PR_REVIEWER:-}" ]]; then + IFS=',' read -ra _reviewers <<< "$PR_REVIEWER" + for _r in "${_reviewers[@]}"; do + _r="${_r#@}"; _r="${_r// /}" # strip @ and whitespace + [[ -n "$_r" ]] && REVIEWER_ARGS+=(--reviewer "$_r") + done + fi + gh pr create \ + --repo "$TARGET_REPO" \ + --head "$BRANCH" \ + --base "$DEFAULT_BRANCH" \ + --title "chore: update plugin releases listings" \ + --body "$PR_BODY" \ + "${REVIEWER_ARGS[@]}" + echo "Created new PR" + fi diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index 2de718c..ac15872 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -26,16 +26,51 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Checkout config + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Post draft notice env: PR_NUMBER: ${{ github.event.pull_request.number }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + BOT_LOGIN: ${{ steps.config.outputs.use_app == 'true' && format('{0}[bot]', steps.app-token.outputs.app-slug) || 'github-actions[bot]' }} GH_REPO: ${{ github.repository }} run: | MARKER="" COMMENT="$MARKER"$'\n'"This PR is currently a draft. Plugin validation will run once the PR is marked ready for review." EXISTING=$(gh pr view $PR_NUMBER --json comments \ - | jq -r --arg marker "$MARKER" '.comments[] | select(.author.login=="github-actions[bot]") | select(.body | contains($marker)) | .id') + | jq -r --arg marker "$MARKER" --arg login "$BOT_LOGIN" '.comments[] | select(.author.login==$login) | select(.body | contains($marker)) | .id') if [ -n "$EXISTING" ]; then gh api "repos/${{ github.repository }}/issues/comments/$EXISTING" -X PATCH -f body="$COMMENT" else @@ -92,10 +127,36 @@ jobs: - name: Re-fetch base branch refs run: git fetch https://github.com/${{ github.repository }} +${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Detect changed plugins id: detect env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} GITHUB_REPOSITORY: ${{ github.repository }} run: | chmod +x .github/scripts/validate/*.sh @@ -112,9 +173,43 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 2 steps: + - name: Checkout config + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Apply labels env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} HAS_NEW_PLUGIN: ${{ needs.detect-changes.outputs.has_new_plugin }} @@ -406,10 +501,36 @@ jobs: - name: Re-fetch base branch refs run: git fetch https://github.com/${{ github.repository }} +${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Validate ${{ matrix.plugin }} id: validate env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} GITHUB_REPOSITORY: ${{ github.repository }} run: | chmod +x .github/scripts/validate/*.sh @@ -445,9 +566,37 @@ jobs: - name: Checkout scripts uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 sparse-checkout: .github/scripts sparse-checkout-cone-mode: false + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Download all report fragments uses: actions/download-artifact@v4 with: @@ -473,7 +622,7 @@ jobs: - name: Aggregate and post comment id: report env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} GITHUB_REPOSITORY: ${{ github.repository }} DISCORD_URL: ${{ vars.DISCORD_URL }} CODEQL_RESULT: ${{ needs.codeql-analyze.outputs.codeql_status }} @@ -511,12 +660,40 @@ jobs: - name: Checkout scripts uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 sparse-checkout: .github/scripts sparse-checkout-cone-mode: false + - name: Load app ID from config + id: config + env: + GH_APP_ID: ${{ vars.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + run: | + if [[ -n "${GH_APP_ID:-}" && -n "${GH_APP_PRIVATE_KEY:-}" ]]; then + echo "app_id=${GH_APP_ID}" >> "$GITHUB_OUTPUT" + echo "use_app=true" >> "$GITHUB_OUTPUT" + else + echo "use_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Generate GitHub App token + if: steps.config.outputs.use_app == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + run: | + printf '## ⚠️ GitHub App token not available\n\nGH_APP_ID or GH_APP_PRIVATE_KEY not configured. Falling back to `GITHUB_TOKEN` (github-actions[bot] identity).\n' >> "$GITHUB_STEP_SUMMARY" + - name: Post close comment and close PR env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} GITHUB_REPOSITORY: ${{ github.repository }} DISCORD_URL: ${{ vars.DISCORD_URL }} CLOSE_REASON: ${{ needs.detect-changes.outputs.close_reason }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63f17ce..da9f8da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,10 +16,14 @@ plugins/ your-plugin-name/ plugin.json # required + main.py # your plugin's entry point + ... # any other Python files, assets, or subdirectories README.md # optional but recommended logo.png # optional; displayed in the plugin browser ``` +All files inside your plugin folder - `main.py`, helper modules, assets, subdirectories - are automatically packaged into a ZIP on merge. There is no separate build step. + Plugin folder names must be **lowercase-kebab-case** (e.g. `my-plugin-name`). ## Submitting a Plugin @@ -30,7 +34,7 @@ Plugin folder names must be **lowercase-kebab-case** (e.g. `my-plugin-name`). 4. Optionally add a `README.md` and `logo.png` 5. Submit a pull request to `main` -For **updates**, increment the version in `plugin.json` - the validation workflow enforces this. +For **updates**, increment the version in `plugin.json` - the validation workflow enforces this. Exception: some metadata-only fields (`description`, `repo_url`, `discord_thread`, `maintainers`, `min_dispatcharr_version`, `max_dispatcharr_version`, `deprecated`, `unlisted`) can be updated without a version bump. ## `plugin.json` Spec @@ -95,7 +99,7 @@ Automated validation runs on every PR and posts a comment with results. The foll | JSON syntax | Must be valid JSON | | Required fields | `name`, `version`, `description`, `author` or `maintainers`, `license` | | Version format | Must be `MAJOR.MINOR.PATCH` (semver) | -| Version bump | Must be greater than the current published version | +| Version bump | Must be greater than the current published version (see [metadata-only exceptions](#versioning)) | | Permission | PR author must be listed in `author` or `maintainers` | | License | Must be a valid OSI-approved SPDX identifier | | `min_dispatcharr_version` | Must be semver if provided | @@ -129,6 +133,19 @@ Plugins use [semantic versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`): Version increments are enforced by the validation workflow. You cannot submit a PR with the same or lower version than the currently published plugin. +**Metadata-only updates** are an exception - the following fields can be changed without bumping the version: + +- `description` +- `repo_url` +- `discord_thread` +- `maintainers` +- `min_dispatcharr_version` +- `max_dispatcharr_version` +- `deprecated` +- `unlisted` + +All other fields - including `name`, `author`, `license`, and any code changes - require a version bump. + ## Licensing All plugins must be distributed under an [OSI-approved open source license](https://opensource.org/licenses). The `license` field is required in `plugin.json` and must be a valid [SPDX identifier](https://spdx.org/licenses/). diff --git a/README.md b/README.md index 8bd608f..87d164a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A repository for publishing and distributing Dispatcharr Python plugins with aut ## How It Works -Each plugin lives in `plugins//` and must contain a valid `plugin.json`. When a PR is merged to `main`, plugins are automatically packaged and published to the [`releases` branch](https://github.com/Dispatcharr/Plugins/tree/releases). +Each plugin lives in `plugins//` and must contain a valid `plugin.json` alongside `main.py` and any other code or assets. When a PR is merged to `main`, everything in the plugin folder is automatically packaged into a ZIP and published to the [`releases` branch](https://github.com/Dispatcharr/Plugins/tree/releases) - no separate build step required. ### PR Validation