diff --git a/.github/ISSUE_TEMPLATE/repo-issue.yml b/.github/ISSUE_TEMPLATE/repo-issue.yml index d7e20166..b5a4f22e 100644 --- a/.github/ISSUE_TEMPLATE/repo-issue.yml +++ b/.github/ISSUE_TEMPLATE/repo-issue.yml @@ -5,6 +5,9 @@ body: - type: markdown attributes: value: | + > **Workflow transition** + > The repository's workflows recently changed. Please bear with us while we work through expected issues and hiccups. + > **Plugin issues belong elsewhere.** > If your issue is about a specific plugin (bugs, feature requests, compatibility), use one of these instead: > - The plugin's own GitHub repository (linked in its `plugin.json`) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d2ce50c7..53f4138f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -17,7 +17,13 @@ ## About this submission - + ## Pre-submission checklist diff --git a/.github/actions/gh-app-token/action.yml b/.github/actions/gh-app-token/action.yml new file mode 100644 index 00000000..4bf5bf42 --- /dev/null +++ b/.github/actions/gh-app-token/action.yml @@ -0,0 +1,85 @@ +name: GitHub App token +description: >- + Mint a GitHub App installation token when GH_APP_ID + GH_APP_PRIVATE_KEY are + configured, otherwise fall back to the workflow GITHUB_TOKEN + (github-actions[bot]) with the same step-summary warning. Replaces the token + block that was copy-pasted across the plugin workflows. + +inputs: + app-id: + description: GitHub App client/app id (pass vars.GH_APP_ID) + required: false + default: "" + private-key: + description: >- + GitHub App private key (pass secrets.GH_APP_PRIVATE_KEY). Composite actions + do not inherit secrets, so it must be provided as an input. + required: false + default: "" + owner: + description: Optional owner to scope the installation token to. + required: false + default: "" + +outputs: + token: + description: The App installation token, or the fallback github.token. + value: ${{ steps.result.outputs.token }} + app-slug: + description: The App slug (empty when falling back). + value: ${{ steps.app-token.outputs.app-slug }} + use_app: + description: "'true' when an App token was minted, 'false' on fallback." + value: ${{ steps.config.outputs.use_app }} + bot-login: + description: The bot login to attribute actions to. + value: ${{ steps.result.outputs.bot_login }} + +runs: + using: composite + steps: + - name: Resolve configuration + id: config + shell: bash + env: + GH_APP_ID: ${{ inputs.app-id }} + GH_APP_PRIVATE_KEY: ${{ inputs.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@v3 + with: + client-id: ${{ steps.config.outputs.app_id }} + private-key: ${{ inputs.private-key }} + owner: ${{ inputs.owner }} + + - name: Log fallback to actions token + if: steps.config.outputs.use_app != 'true' + shell: bash + 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: Expose token + bot login + id: result + shell: bash + env: + USE_APP: ${{ steps.config.outputs.use_app }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + FALLBACK_TOKEN: ${{ github.token }} + run: | + if [[ "$USE_APP" == "true" ]]; then + echo "token=$APP_TOKEN" >> "$GITHUB_OUTPUT" + echo "bot_login=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" + else + echo "token=$FALLBACK_TOKEN" >> "$GITHUB_OUTPUT" + echo "bot_login=github-actions[bot]" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/trusted-checkout/action.yml b/.github/actions/trusted-checkout/action.yml new file mode 100644 index 00000000..8feda69d --- /dev/null +++ b/.github/actions/trusted-checkout/action.yml @@ -0,0 +1,64 @@ +name: Trusted checkout +description: >- + The pull_request_target checkout "dance": check out the base branch's trusted + code, sparse-checkout the untrusted fork's plugins/ as data only, then restore + the trusted code and re-fetch the base refs. Fork content is never executed; + pluginctl always comes from the base branch. Replaces the sequence duplicated + across detect-changes / validate-plugin. + +inputs: + base-ref: + description: PR base branch (github.event.pull_request.base.ref) + required: true + head-repo: + description: Fork repo full name (github.event.pull_request.head.repo.full_name) + required: true + head-sha: + description: Fork head SHA (github.event.pull_request.head.sha) + required: true + repository: + description: This repository (github.repository) + required: true + +runs: + using: composite + steps: + - name: Checkout base branch code (trusted) + uses: actions/checkout@v6 + with: + repository: ${{ inputs.repository }} + ref: ${{ inputs.base-ref }} + fetch-depth: 0 + sparse-checkout: .github + sparse-checkout-cone-mode: false + + - name: Fetch base branch + shell: bash + run: git fetch origin ${{ inputs.base-ref }} + + - name: Save trusted code before fork checkout + shell: bash + run: cp -r .github /tmp/trusted-dotgithub + + - name: Checkout PR plugins (untrusted content only) + uses: actions/checkout@v6 + with: + repository: ${{ inputs.head-repo }} + ref: ${{ inputs.head-sha }} + fetch-depth: 0 + sparse-checkout: plugins + sparse-checkout-cone-mode: false + clean: false + # Fork content is only read as data (sparse plugins/), never executed; + # trusted code is saved/restored around this step. + allow-unsafe-pr-checkout: true + + - name: Restore trusted code + shell: bash + run: | + rm -rf .github + cp -r /tmp/trusted-dotgithub .github + + - name: Re-fetch base branch refs + shell: bash + run: git fetch https://github.com/${{ inputs.repository }} +${{ inputs.base-ref }}:refs/remotes/origin/${{ inputs.base-ref }} diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 4551161d..26d888aa 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,5 +1,5 @@ # Extra CodeQL query applied alongside the `security-extended` suite used by -# validate-plugin.yml. `packs:` is required here (rather than the simpler +# _codeql-scan.yml. `packs:` is required here (rather than the simpler # `packs:` input on codeql-action/init) because that input only works for # single-language analyses, and our workflow can initialize multiple # languages at once. @@ -14,6 +14,7 @@ # comments are ignored. queries: - uses: security-extended + - uses: ./.github/codeql/queries/python packs: python: - codeql/python-queries:AlertSuppression.ql @@ -27,3 +28,6 @@ packs: - codeql/java-queries:AlertSuppression.ql cpp: - codeql/cpp-queries:AlertSuppression.ql +paths-ignore: + - .github/codeql/** + - _trusted/** diff --git a/.github/codeql/queries/python/AsyncioProcessApi.ql b/.github/codeql/queries/python/AsyncioProcessApi.ql new file mode 100644 index 00000000..bec91e71 --- /dev/null +++ b/.github/codeql/queries/python/AsyncioProcessApi.ql @@ -0,0 +1,18 @@ +/** + * @name Plugin asyncio process API bypass + * @description asyncio subprocess APIs bypass Dispatcharr's guarded subprocess module. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/asyncio-process-api + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.ApiGraphs + +from API::CallNode call +where + call = API::moduleImport("asyncio") + .getMember(["create_subprocess_exec", "create_subprocess_shell"]).getACall() +select call, "This asyncio subprocess API bypasses Dispatcharr's guarded subprocess module." diff --git a/.github/codeql/queries/python/BackgroundTasksUsage.ql b/.github/codeql/queries/python/BackgroundTasksUsage.ql new file mode 100644 index 00000000..0702f205 --- /dev/null +++ b/.github/codeql/queries/python/BackgroundTasksUsage.ql @@ -0,0 +1,20 @@ +/** + * @name Plugin background task capability use + * @description Dispatcharr's plugin context dispatcher requires the background_tasks capability. + * @kind problem + * @problem.severity warning + * @precision medium + * @id plugin/capability-contract/background-tasks + * @tags security external/cwe/cwe-269 + */ + +import python + +from Call call, Subscript helper, StringLiteral key, Name context +where + call.getFunc() = helper and + helper.getObject() = context and + context.getId() = "context" and + helper.getIndex() = key and + key.getText() = "dispatch_task" +select call, "This plugin task dispatch requires the `background_tasks` capability." diff --git a/.github/codeql/queries/python/BuiltinsMutation.ql b/.github/codeql/queries/python/BuiltinsMutation.ql new file mode 100644 index 00000000..30afb17d --- /dev/null +++ b/.github/codeql/queries/python/BuiltinsMutation.ql @@ -0,0 +1,34 @@ +/** + * @name Plugin mutation of builtins + * @description Mutating the plugin's builtins mapping can replace its guarded import or open hooks. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/builtins-mutation + * @tags security external/cwe/cwe-693 + */ + +import python + +predicate builtinsObject(Expr expression) { + exists(Name builtins | expression = builtins and builtins.getId() = "__builtins__") +} + +from Expr candidate +where + exists(AssignStmt assignment, Expr target | + assignment.getATarget() = target and + ( + exists(Attribute attribute | target = attribute and builtinsObject(attribute.getObject())) or + exists(Subscript subscript | target = subscript and builtinsObject(subscript.getObject())) + ) and + candidate = target + ) + or + exists(Call call, Name setattr | + call.getFunc() = setattr and + setattr.getId() = "setattr" and + builtinsObject(call.getArg(0)) and + candidate = call + ) +select candidate, "Mutating __builtins__ can replace Dispatcharr's plugin-local guarded import or open hooks." diff --git a/.github/codeql/queries/python/CeleryDispatchUsage.ql b/.github/codeql/queries/python/CeleryDispatchUsage.ql new file mode 100644 index 00000000..8802c2a4 --- /dev/null +++ b/.github/codeql/queries/python/CeleryDispatchUsage.ql @@ -0,0 +1,20 @@ +/** + * @name Plugin internal task dispatch capability use + * @description Dispatcharr's allowlisted internal-task dispatcher requires the celery_dispatch capability. + * @kind problem + * @problem.severity warning + * @precision medium + * @id plugin/capability-contract/celery-dispatch + * @tags security external/cwe/cwe-269 + */ + +import python + +from Call call, Subscript helper, StringLiteral key, Name context +where + call.getFunc() = helper and + helper.getObject() = context and + context.getId() = "context" and + helper.getIndex() = key and + key.getText() = "dispatch_internal_task" +select call, "This internal task dispatch requires the `celery_dispatch` capability." diff --git a/.github/codeql/queries/python/CtypesUsage.ql b/.github/codeql/queries/python/CtypesUsage.ql new file mode 100644 index 00000000..40e4369d --- /dev/null +++ b/.github/codeql/queries/python/CtypesUsage.ql @@ -0,0 +1,15 @@ +/** + * @name Plugin use of ctypes + * @description Native ctypes access can execute outside Dispatcharr's plugin-local wrappers. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/ctypes-usage + * @tags security external/cwe/cwe-693 + */ + +import python + +from Import imp +where imp.getAnImportedModuleName() = "ctypes" +select imp, "ctypes can load native code outside Dispatcharr's plugin capability wrappers." diff --git a/.github/codeql/queries/python/DirectBuiltinsAccess.ql b/.github/codeql/queries/python/DirectBuiltinsAccess.ql new file mode 100644 index 00000000..9aa482df --- /dev/null +++ b/.github/codeql/queries/python/DirectBuiltinsAccess.ql @@ -0,0 +1,60 @@ +/** + * @name Plugin direct access to original builtins + * @description Accessing the global builtins module can bypass the plugin-local open and import wrappers. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/direct-builtins-access + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.dataflow.new.DataFlow + +predicate constantString(Expr expression, string value) { + exists(StringLiteral literal | + expression = literal and value = literal.getText() + ) + or + exists(BinaryExpr addition, Expr left, Expr right, string leftValue, string rightValue | + expression = addition and + addition.getLeft() = left and + addition.getRight() = right and + constantString(left, leftValue) and + constantString(right, rightValue) and + value = leftValue + rightValue + ) +} + +predicate dynamicBuiltinsAccess(Call access) { + exists(Call importCall, Name importFunction, Expr moduleName, Expr moduleNameSource, + Call memberLookup, Name memberLookupFunction, Expr importedModule, + Expr memberName, Expr memberNameSource, string memberValue | + importCall.getFunc() = importFunction and importFunction.getId() = "__import__" and + importCall.getArg(0) = moduleName and + constantString(moduleNameSource, "builtins") and + DataFlow::localFlow(DataFlow::exprNode(moduleNameSource), DataFlow::exprNode(moduleName)) and + memberLookup.getFunc() = memberLookupFunction and memberLookupFunction.getId() = "getattr" and + memberLookup.getArg(0) = importedModule and + DataFlow::localFlow(DataFlow::exprNode(importCall), DataFlow::exprNode(importedModule)) and + memberLookup.getArg(1) = memberName and + constantString(memberNameSource, memberValue) and + DataFlow::localFlow(DataFlow::exprNode(memberNameSource), DataFlow::exprNode(memberName)) and + memberValue in ["open", "__import__"] and + access = memberLookup + ) +} + +from Call call +where + ( + exists(Attribute function, Name mod | + call.getFunc() = function and + function.getObject() = mod and + mod.getId() = "builtins" and + function.getName() in ["open", "__import__"] + ) + or + dynamicBuiltinsAccess(call) + ) +select call, "Accessing the original builtins module bypasses Dispatcharr's plugin-local wrappers." diff --git a/.github/codeql/queries/python/FilesystemApiBypass.ql b/.github/codeql/queries/python/FilesystemApiBypass.ql new file mode 100644 index 00000000..fe7ee6dc --- /dev/null +++ b/.github/codeql/queries/python/FilesystemApiBypass.ql @@ -0,0 +1,25 @@ +/** + * @name Plugin direct filesystem API bypass + * @description Direct filesystem APIs bypass Dispatcharr's plugin-local builtins.open wrapper. + * @kind problem + * @problem.severity warning + * @precision medium + * @id plugin/sandbox-bypass/filesystem-api + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.ApiGraphs + +from API::CallNode call +where + ( + call = API::moduleImport("io").getMember("open").getACall() or + call = API::moduleImport("os").getMember("open").getACall() or + call = API::moduleImport("shutil") + .getMember(["copy", "copy2", "copyfile", "copytree", "move", "rmtree"]).getACall() or + call = API::moduleImport("pathlib").getMember("Path").getACall().getReturn() + .getMember(["open", "write_text", "write_bytes", "touch", "mkdir", "unlink", "rename", "replace"]) + .getACall() + ) +select call, "This direct filesystem API bypasses Dispatcharr's plugin-local open wrapper." diff --git a/.github/codeql/queries/python/ImportlibWrappedModule.ql b/.github/codeql/queries/python/ImportlibWrappedModule.ql new file mode 100644 index 00000000..a00b3bdd --- /dev/null +++ b/.github/codeql/queries/python/ImportlibWrappedModule.ql @@ -0,0 +1,25 @@ +/** + * @name Plugin importlib access to wrapped module + * @description Loading a guarded module through importlib bypasses the plugin-local import wrapper. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/importlib-wrapped-module + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.ApiGraphs + +predicate wrappedModuleName(Expr expression) { + exists(StringLiteral literal | + expression = literal and + literal.getText() in ["subprocess", "socket", "requests", "urllib.request"] + ) +} + +from API::CallNode call +where + call = API::moduleImport("importlib").getMember("import_module").getACall() and + wrappedModuleName(call.getArg(0).asExpr()) +select call, "Loading this module through importlib bypasses Dispatcharr's plugin-local import wrapper." diff --git a/.github/codeql/queries/python/NetworkListenerUsage.ql b/.github/codeql/queries/python/NetworkListenerUsage.ql new file mode 100644 index 00000000..539e4b9e --- /dev/null +++ b/.github/codeql/queries/python/NetworkListenerUsage.ql @@ -0,0 +1,17 @@ +/** + * @name Plugin network listener capability use + * @description Binding or listening on a socket requires Dispatcharr's network_listener capability. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/capability-contract/network-listener + * @tags security external/cwe/cwe-269 + */ + +import python + +from Call call, Attribute function +where + call.getFunc() = function and + function.getName() in ["bind", "listen"] +select call, "This socket listener operation requires the `network_listener` capability." diff --git a/.github/codeql/queries/python/OsProcessApi.ql b/.github/codeql/queries/python/OsProcessApi.ql new file mode 100644 index 00000000..f3b554ad --- /dev/null +++ b/.github/codeql/queries/python/OsProcessApi.ql @@ -0,0 +1,18 @@ +/** + * @name Plugin OS process API bypass + * @description Direct os process APIs bypass Dispatcharr's guarded subprocess module. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/os-process-api + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.ApiGraphs + +from API::CallNode call +where + call = API::moduleImport("os") + .getMember(["system", "popen", "execv", "execve", "spawnv", "spawnve"]).getACall() +select call, "This os process API bypasses Dispatcharr's guarded subprocess module." diff --git a/.github/codeql/queries/python/OutboundNetworkUsage.ql b/.github/codeql/queries/python/OutboundNetworkUsage.ql new file mode 100644 index 00000000..bad6782c --- /dev/null +++ b/.github/codeql/queries/python/OutboundNetworkUsage.ql @@ -0,0 +1,21 @@ +/** + * @name Plugin outbound network capability use + * @description Selected HTTP and socket connection APIs require Dispatcharr's outbound_network capability. + * @kind problem + * @problem.severity warning + * @precision medium + * @id plugin/capability-contract/outbound-network + * @tags security external/cwe/cwe-269 + */ + +import python +import semmle.python.ApiGraphs + +from API::CallNode call +where + ( + call = API::moduleImport("requests") + .getMember(["get", "post", "put", "patch", "delete", "request"]).getACall() or + call = API::moduleImport("urllib.request").getMember(["urlopen", "urlretrieve"]).getACall() + ) +select call, "This outbound network operation requires the `outbound_network` capability." diff --git a/.github/codeql/queries/python/PersistentServiceUsage.ql b/.github/codeql/queries/python/PersistentServiceUsage.ql new file mode 100644 index 00000000..92285d73 --- /dev/null +++ b/.github/codeql/queries/python/PersistentServiceUsage.ql @@ -0,0 +1,15 @@ +/** + * @name Plugin persistent service capability use + * @description Defining Dispatcharr's leader-acquisition hook requires the persistent_service capability. + * @kind problem + * @problem.severity warning + * @precision medium + * @id plugin/capability-contract/persistent-service + * @tags security external/cwe/cwe-269 + */ + +import python + +from Function hook +where hook.getName() = "on_leader_acquired" +select hook, "This leader service hook requires the `persistent_service` capability." diff --git a/.github/codeql/queries/python/RequestsSessionApi.ql b/.github/codeql/queries/python/RequestsSessionApi.ql new file mode 100644 index 00000000..5b718fb6 --- /dev/null +++ b/.github/codeql/queries/python/RequestsSessionApi.ql @@ -0,0 +1,17 @@ +/** + * @name Plugin requests session bypass + * @description Constructing a requests Session bypasses Dispatcharr's guarded module-level requests helpers. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/requests-session-api + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.ApiGraphs + +from API::CallNode call +where + call = API::moduleImport("requests").getMember(["Session", "session"]).getACall() +select call, "Constructing a requests session bypasses Dispatcharr's guarded module-level HTTP helpers." diff --git a/.github/codeql/queries/python/SocketConvenienceApi.ql b/.github/codeql/queries/python/SocketConvenienceApi.ql new file mode 100644 index 00000000..9df1b3eb --- /dev/null +++ b/.github/codeql/queries/python/SocketConvenienceApi.ql @@ -0,0 +1,17 @@ +/** + * @name Plugin socket convenience API bypass + * @description socket.create_connection bypasses the socket constructor proxy that Dispatcharr guards. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/sandbox-bypass/socket-convenience-api + * @tags security external/cwe/cwe-693 + */ + +import python +import semmle.python.ApiGraphs + +from API::CallNode call +where + call = API::moduleImport("socket").getMember("create_connection").getACall() +select call, "socket.create_connection bypasses Dispatcharr's guarded socket constructor." diff --git a/.github/codeql/queries/python/SubprocessUsage.ql b/.github/codeql/queries/python/SubprocessUsage.ql new file mode 100644 index 00000000..82aac59a --- /dev/null +++ b/.github/codeql/queries/python/SubprocessUsage.ql @@ -0,0 +1,62 @@ +/** + * @name Plugin subprocess capability use + * @description Calling a subprocess API requires Dispatcharr's subprocess capability. + * @kind problem + * @problem.severity warning + * @precision high + * @id plugin/capability-contract/subprocess + * @tags security external/cwe/cwe-269 + */ + +import python +import semmle.python.ApiGraphs +import semmle.python.dataflow.new.DataFlow + +predicate constantString(Expr expression, string value) { + exists(StringLiteral literal | + expression = literal and value = literal.getText() + ) + or + exists(BinaryExpr addition, Expr left, Expr right, string leftValue, string rightValue | + expression = addition and + addition.getLeft() = left and + addition.getRight() = right and + constantString(left, leftValue) and + constantString(right, rightValue) and + value = leftValue + rightValue + ) +} + +predicate dynamicSubprocessCall(Call call) { + exists(Call importLookup, Name importLookupFunction, Name importlib, + Call importCall, Call memberLookup, Name memberLookupFunction, + Expr importMember, Expr moduleName, Expr memberName, Expr importedModule, Expr resolvedMember, + Expr moduleNameSource, Expr memberNameSource, string moduleValue, string memberValue | + importLookup.getFunc() = importLookupFunction and + importLookupFunction.getId() = "getattr" and + importLookup.getArg(0) = importlib and importlib.getId() = "importlib" and + importLookup.getArg(1) = importMember and constantString(importMember, "import_module") and + DataFlow::localFlow(DataFlow::exprNode(importLookup), DataFlow::exprNode(importCall.getFunc())) and + importCall.getArg(0) = moduleName and + constantString(moduleNameSource, moduleValue) and + DataFlow::localFlow(DataFlow::exprNode(moduleNameSource), DataFlow::exprNode(moduleName)) and + moduleValue = "subprocess" and + DataFlow::localFlow(DataFlow::exprNode(importCall), DataFlow::exprNode(importedModule)) and + memberLookup.getFunc() = memberLookupFunction and memberLookupFunction.getId() = "getattr" and + memberLookup.getArg(0) = importedModule and + memberLookup.getArg(1) = memberName and + constantString(memberNameSource, memberValue) and + DataFlow::localFlow(DataFlow::exprNode(memberNameSource), DataFlow::exprNode(memberName)) and + memberValue in ["Popen", "run", "call", "check_call", "check_output"] and + DataFlow::localFlow(DataFlow::exprNode(memberLookup), DataFlow::exprNode(resolvedMember)) and + call.getFunc() = resolvedMember + ) +} + +from Call call +where + call = API::moduleImport("subprocess") + .getMember(["Popen", "run", "call", "check_call", "check_output"]).getACall().asExpr() + or + dynamicSubprocessCall(call) +select call, "This subprocess call requires the `subprocess` capability." diff --git a/.github/codeql/queries/python/SysModulesTamper.ql b/.github/codeql/queries/python/SysModulesTamper.ql new file mode 100644 index 00000000..83380629 --- /dev/null +++ b/.github/codeql/queries/python/SysModulesTamper.ql @@ -0,0 +1,45 @@ +/** + * @name Plugin tampering with sys.modules + * @description Changing sys.modules can replace or remove sandbox-wrapped modules. + * @kind problem + * @problem.severity warning + * @precision medium + * @id plugin/sandbox-bypass/sys-modules-tamper + * @tags security external/cwe/cwe-693 + */ + +import python + +predicate sensitiveModuleName(Expr expression) { + exists(StringLiteral literal | + expression = literal and + literal.getText() in ["subprocess", "socket", "urllib.request", "requests", + "apps.plugins", "apps.plugins.sandbox", "apps.plugins.context"] + ) +} + +predicate sysModules(Attribute attribute) { + exists(Name sys | + attribute.getObject() = sys and sys.getId() = "sys" and attribute.getName() = "modules" + ) +} + +from Expr candidate +where + exists(AssignStmt assignment, Subscript target, Attribute modules | + assignment.getATarget() = target and + target.getObject() = modules and + sysModules(modules) and + sensitiveModuleName(target.getIndex()) and + candidate = target + ) + or + exists(Call call, Attribute pop, Attribute modules | + call.getFunc() = pop and + pop.getName() = "pop" and + pop.getObject() = modules and + sysModules(modules) and + sensitiveModuleName(call.getArg(0)) and + candidate = call + ) +select candidate, "Changing this sys.modules entry can bypass Dispatcharr's plugin import wrappers." diff --git a/.github/codeql/queries/python/codeql-pack.lock.yml b/.github/codeql/queries/python/codeql-pack.lock.yml new file mode 100644 index 00000000..7e8fb941 --- /dev/null +++ b/.github/codeql/queries/python/codeql-pack.lock.yml @@ -0,0 +1,30 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.33 + codeql/controlflow: + version: 2.0.43 + codeql/dataflow: + version: 2.1.15 + codeql/mad: + version: 1.0.59 + codeql/python-all: + version: 7.2.7 + codeql/regex: + version: 1.0.59 + codeql/ssa: + version: 2.0.35 + codeql/threat-models: + version: 1.0.59 + codeql/tutorial: + version: 1.0.59 + codeql/typetracking: + version: 2.0.43 + codeql/util: + version: 2.0.46 + codeql/xml: + version: 1.0.59 + codeql/yaml: + version: 1.0.59 +compiled: false diff --git a/.github/codeql/queries/python/qlpack.yml b/.github/codeql/queries/python/qlpack.yml new file mode 100644 index 00000000..4e0fe10e --- /dev/null +++ b/.github/codeql/queries/python/qlpack.yml @@ -0,0 +1,4 @@ +name: dispatcharr/plugin-capability-security-queries +version: 0.2.0 +dependencies: + codeql/python-all: "*" diff --git a/.github/codeql/queries/python/tests/AsyncioProcessApi/AsyncioProcessApi.expected b/.github/codeql/queries/python/tests/AsyncioProcessApi/AsyncioProcessApi.expected new file mode 100644 index 00000000..a321d987 --- /dev/null +++ b/.github/codeql/queries/python/tests/AsyncioProcessApi/AsyncioProcessApi.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:38 | ControlFlowNode for Attribute() | This asyncio subprocess API bypasses Dispatcharr's guarded subprocess module. | diff --git a/.github/codeql/queries/python/tests/AsyncioProcessApi/AsyncioProcessApi.qlref b/.github/codeql/queries/python/tests/AsyncioProcessApi/AsyncioProcessApi.qlref new file mode 100644 index 00000000..632b8110 --- /dev/null +++ b/.github/codeql/queries/python/tests/AsyncioProcessApi/AsyncioProcessApi.qlref @@ -0,0 +1 @@ +AsyncioProcessApi.ql diff --git a/.github/codeql/queries/python/tests/AsyncioProcessApi/bad.py b/.github/codeql/queries/python/tests/AsyncioProcessApi/bad.py new file mode 100644 index 00000000..67b8e03f --- /dev/null +++ b/.github/codeql/queries/python/tests/AsyncioProcessApi/bad.py @@ -0,0 +1,3 @@ +import asyncio + +asyncio.create_subprocess_exec("true") diff --git a/.github/codeql/queries/python/tests/AsyncioProcessApi/good.py b/.github/codeql/queries/python/tests/AsyncioProcessApi/good.py new file mode 100644 index 00000000..c6a2de02 --- /dev/null +++ b/.github/codeql/queries/python/tests/AsyncioProcessApi/good.py @@ -0,0 +1,3 @@ +import asyncio + +asyncio.sleep(1) diff --git a/.github/codeql/queries/python/tests/BackgroundTasksUsage/BackgroundTasksUsage.expected b/.github/codeql/queries/python/tests/BackgroundTasksUsage/BackgroundTasksUsage.expected new file mode 100644 index 00000000..7a5e80b7 --- /dev/null +++ b/.github/codeql/queries/python/tests/BackgroundTasksUsage/BackgroundTasksUsage.expected @@ -0,0 +1 @@ +| bad.py:1:1:1:35 | Subscript() | This plugin task dispatch requires the `background_tasks` capability. | diff --git a/.github/codeql/queries/python/tests/BackgroundTasksUsage/BackgroundTasksUsage.qlref b/.github/codeql/queries/python/tests/BackgroundTasksUsage/BackgroundTasksUsage.qlref new file mode 100644 index 00000000..ebdc95c4 --- /dev/null +++ b/.github/codeql/queries/python/tests/BackgroundTasksUsage/BackgroundTasksUsage.qlref @@ -0,0 +1 @@ +BackgroundTasksUsage.ql diff --git a/.github/codeql/queries/python/tests/BackgroundTasksUsage/bad.py b/.github/codeql/queries/python/tests/BackgroundTasksUsage/bad.py new file mode 100644 index 00000000..659f9af8 --- /dev/null +++ b/.github/codeql/queries/python/tests/BackgroundTasksUsage/bad.py @@ -0,0 +1 @@ +context["dispatch_task"]("refresh") diff --git a/.github/codeql/queries/python/tests/BackgroundTasksUsage/good.py b/.github/codeql/queries/python/tests/BackgroundTasksUsage/good.py new file mode 100644 index 00000000..f5f0ad30 --- /dev/null +++ b/.github/codeql/queries/python/tests/BackgroundTasksUsage/good.py @@ -0,0 +1 @@ +context["report_progress"](100) diff --git a/.github/codeql/queries/python/tests/BuiltinsMutation/BuiltinsMutation.expected b/.github/codeql/queries/python/tests/BuiltinsMutation/BuiltinsMutation.expected new file mode 100644 index 00000000..40e137ae --- /dev/null +++ b/.github/codeql/queries/python/tests/BuiltinsMutation/BuiltinsMutation.expected @@ -0,0 +1,3 @@ +| bad.py:1:1:1:20 | Subscript | Mutating __builtins__ can replace Dispatcharr's plugin-local guarded import or open hooks. | +| bad.py:2:1:2:23 | Attribute | Mutating __builtins__ can replace Dispatcharr's plugin-local guarded import or open hooks. | +| bad.py:3:1:3:59 | setattr() | Mutating __builtins__ can replace Dispatcharr's plugin-local guarded import or open hooks. | diff --git a/.github/codeql/queries/python/tests/BuiltinsMutation/BuiltinsMutation.qlref b/.github/codeql/queries/python/tests/BuiltinsMutation/BuiltinsMutation.qlref new file mode 100644 index 00000000..2dbbf509 --- /dev/null +++ b/.github/codeql/queries/python/tests/BuiltinsMutation/BuiltinsMutation.qlref @@ -0,0 +1 @@ +BuiltinsMutation.ql diff --git a/.github/codeql/queries/python/tests/BuiltinsMutation/bad.py b/.github/codeql/queries/python/tests/BuiltinsMutation/bad.py new file mode 100644 index 00000000..98973ce1 --- /dev/null +++ b/.github/codeql/queries/python/tests/BuiltinsMutation/bad.py @@ -0,0 +1,3 @@ +__builtins__["open"] = lambda *args, **kwargs: None +__builtins__.__import__ = lambda *args, **kwargs: None +setattr(__builtins__, "open", lambda *args, **kwargs: None) diff --git a/.github/codeql/queries/python/tests/BuiltinsMutation/good.py b/.github/codeql/queries/python/tests/BuiltinsMutation/good.py new file mode 100644 index 00000000..d097d737 --- /dev/null +++ b/.github/codeql/queries/python/tests/BuiltinsMutation/good.py @@ -0,0 +1,2 @@ +mapping = {"open": None} +mapping["open"] = lambda *args, **kwargs: None diff --git a/.github/codeql/queries/python/tests/CeleryDispatchUsage/CeleryDispatchUsage.expected b/.github/codeql/queries/python/tests/CeleryDispatchUsage/CeleryDispatchUsage.expected new file mode 100644 index 00000000..0f67bace --- /dev/null +++ b/.github/codeql/queries/python/tests/CeleryDispatchUsage/CeleryDispatchUsage.expected @@ -0,0 +1 @@ +| bad.py:1:1:1:59 | Subscript() | This internal task dispatch requires the `celery_dispatch` capability. | diff --git a/.github/codeql/queries/python/tests/CeleryDispatchUsage/CeleryDispatchUsage.qlref b/.github/codeql/queries/python/tests/CeleryDispatchUsage/CeleryDispatchUsage.qlref new file mode 100644 index 00000000..6e72165d --- /dev/null +++ b/.github/codeql/queries/python/tests/CeleryDispatchUsage/CeleryDispatchUsage.qlref @@ -0,0 +1 @@ +CeleryDispatchUsage.ql diff --git a/.github/codeql/queries/python/tests/CeleryDispatchUsage/bad.py b/.github/codeql/queries/python/tests/CeleryDispatchUsage/bad.py new file mode 100644 index 00000000..a9142671 --- /dev/null +++ b/.github/codeql/queries/python/tests/CeleryDispatchUsage/bad.py @@ -0,0 +1 @@ +context["dispatch_internal_task"]("apps.epg.tasks.refresh") diff --git a/.github/codeql/queries/python/tests/CeleryDispatchUsage/good.py b/.github/codeql/queries/python/tests/CeleryDispatchUsage/good.py new file mode 100644 index 00000000..659f9af8 --- /dev/null +++ b/.github/codeql/queries/python/tests/CeleryDispatchUsage/good.py @@ -0,0 +1 @@ +context["dispatch_task"]("refresh") diff --git a/.github/codeql/queries/python/tests/CtypesUsage/CtypesUsage.expected b/.github/codeql/queries/python/tests/CtypesUsage/CtypesUsage.expected new file mode 100644 index 00000000..987242e6 --- /dev/null +++ b/.github/codeql/queries/python/tests/CtypesUsage/CtypesUsage.expected @@ -0,0 +1 @@ +| bad.py:1:1:1:13 | Import | ctypes can load native code outside Dispatcharr's plugin capability wrappers. | diff --git a/.github/codeql/queries/python/tests/CtypesUsage/CtypesUsage.qlref b/.github/codeql/queries/python/tests/CtypesUsage/CtypesUsage.qlref new file mode 100644 index 00000000..6db78663 --- /dev/null +++ b/.github/codeql/queries/python/tests/CtypesUsage/CtypesUsage.qlref @@ -0,0 +1 @@ +CtypesUsage.ql diff --git a/.github/codeql/queries/python/tests/CtypesUsage/bad.py b/.github/codeql/queries/python/tests/CtypesUsage/bad.py new file mode 100644 index 00000000..7a36a85b --- /dev/null +++ b/.github/codeql/queries/python/tests/CtypesUsage/bad.py @@ -0,0 +1,3 @@ +import ctypes + +ctypes.CDLL("libexample.so") diff --git a/.github/codeql/queries/python/tests/CtypesUsage/good.py b/.github/codeql/queries/python/tests/CtypesUsage/good.py new file mode 100644 index 00000000..3e416262 --- /dev/null +++ b/.github/codeql/queries/python/tests/CtypesUsage/good.py @@ -0,0 +1,3 @@ +import json + +json.dumps({"safe": True}) diff --git a/.github/codeql/queries/python/tests/DirectBuiltinsAccess/DirectBuiltinsAccess.expected b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/DirectBuiltinsAccess.expected new file mode 100644 index 00000000..48bb7022 --- /dev/null +++ b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/DirectBuiltinsAccess.expected @@ -0,0 +1,2 @@ +| bad.py:3:1:3:37 | Attribute() | Accessing the original builtins module bypasses Dispatcharr's plugin-local wrappers. | +| bad.py:6:1:6:28 | getattr() | Accessing the original builtins module bypasses Dispatcharr's plugin-local wrappers. | diff --git a/.github/codeql/queries/python/tests/DirectBuiltinsAccess/DirectBuiltinsAccess.qlref b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/DirectBuiltinsAccess.qlref new file mode 100644 index 00000000..02d82231 --- /dev/null +++ b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/DirectBuiltinsAccess.qlref @@ -0,0 +1 @@ +DirectBuiltinsAccess.ql diff --git a/.github/codeql/queries/python/tests/DirectBuiltinsAccess/bad.py b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/bad.py new file mode 100644 index 00000000..051111fa --- /dev/null +++ b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/bad.py @@ -0,0 +1,6 @@ +import builtins + +builtins.open("/tmp/plugin.txt", "w") + +module = __import__("built" + "ins") +getattr(module, "o" + "pen") diff --git a/.github/codeql/queries/python/tests/DirectBuiltinsAccess/good.py b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/good.py new file mode 100644 index 00000000..b94c0695 --- /dev/null +++ b/.github/codeql/queries/python/tests/DirectBuiltinsAccess/good.py @@ -0,0 +1,2 @@ +with open("plugin-data.txt", "w") as output: + output.write("ok") diff --git a/.github/codeql/queries/python/tests/FilesystemApiBypass/FilesystemApiBypass.expected b/.github/codeql/queries/python/tests/FilesystemApiBypass/FilesystemApiBypass.expected new file mode 100644 index 00000000..9c9e2806 --- /dev/null +++ b/.github/codeql/queries/python/tests/FilesystemApiBypass/FilesystemApiBypass.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:49 | ControlFlowNode for Attribute() | This direct filesystem API bypasses Dispatcharr's plugin-local open wrapper. | diff --git a/.github/codeql/queries/python/tests/FilesystemApiBypass/FilesystemApiBypass.qlref b/.github/codeql/queries/python/tests/FilesystemApiBypass/FilesystemApiBypass.qlref new file mode 100644 index 00000000..7abff9b8 --- /dev/null +++ b/.github/codeql/queries/python/tests/FilesystemApiBypass/FilesystemApiBypass.qlref @@ -0,0 +1 @@ +FilesystemApiBypass.ql diff --git a/.github/codeql/queries/python/tests/FilesystemApiBypass/bad.py b/.github/codeql/queries/python/tests/FilesystemApiBypass/bad.py new file mode 100644 index 00000000..bf4cfe1a --- /dev/null +++ b/.github/codeql/queries/python/tests/FilesystemApiBypass/bad.py @@ -0,0 +1,3 @@ +from pathlib import Path + +Path("/tmp/plugin.txt").write_text("plugin data") diff --git a/.github/codeql/queries/python/tests/FilesystemApiBypass/good.py b/.github/codeql/queries/python/tests/FilesystemApiBypass/good.py new file mode 100644 index 00000000..b94c0695 --- /dev/null +++ b/.github/codeql/queries/python/tests/FilesystemApiBypass/good.py @@ -0,0 +1,2 @@ +with open("plugin-data.txt", "w") as output: + output.write("ok") diff --git a/.github/codeql/queries/python/tests/ImportlibWrappedModule/ImportlibWrappedModule.expected b/.github/codeql/queries/python/tests/ImportlibWrappedModule/ImportlibWrappedModule.expected new file mode 100644 index 00000000..9cc51019 --- /dev/null +++ b/.github/codeql/queries/python/tests/ImportlibWrappedModule/ImportlibWrappedModule.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:37 | ControlFlowNode for Attribute() | Loading this module through importlib bypasses Dispatcharr's plugin-local import wrapper. | diff --git a/.github/codeql/queries/python/tests/ImportlibWrappedModule/ImportlibWrappedModule.qlref b/.github/codeql/queries/python/tests/ImportlibWrappedModule/ImportlibWrappedModule.qlref new file mode 100644 index 00000000..19b701b8 --- /dev/null +++ b/.github/codeql/queries/python/tests/ImportlibWrappedModule/ImportlibWrappedModule.qlref @@ -0,0 +1 @@ +ImportlibWrappedModule.ql diff --git a/.github/codeql/queries/python/tests/ImportlibWrappedModule/bad.py b/.github/codeql/queries/python/tests/ImportlibWrappedModule/bad.py new file mode 100644 index 00000000..a7db16be --- /dev/null +++ b/.github/codeql/queries/python/tests/ImportlibWrappedModule/bad.py @@ -0,0 +1,3 @@ +import importlib + +importlib.import_module("subprocess") diff --git a/.github/codeql/queries/python/tests/ImportlibWrappedModule/good.py b/.github/codeql/queries/python/tests/ImportlibWrappedModule/good.py new file mode 100644 index 00000000..d1c3f369 --- /dev/null +++ b/.github/codeql/queries/python/tests/ImportlibWrappedModule/good.py @@ -0,0 +1,3 @@ +import importlib + +importlib.import_module("json") diff --git a/.github/codeql/queries/python/tests/NetworkListenerUsage/NetworkListenerUsage.expected b/.github/codeql/queries/python/tests/NetworkListenerUsage/NetworkListenerUsage.expected new file mode 100644 index 00000000..3f785007 --- /dev/null +++ b/.github/codeql/queries/python/tests/NetworkListenerUsage/NetworkListenerUsage.expected @@ -0,0 +1 @@ +| bad.py:1:1:1:30 | Attribute() | This socket listener operation requires the `network_listener` capability. | diff --git a/.github/codeql/queries/python/tests/NetworkListenerUsage/NetworkListenerUsage.qlref b/.github/codeql/queries/python/tests/NetworkListenerUsage/NetworkListenerUsage.qlref new file mode 100644 index 00000000..0df585cd --- /dev/null +++ b/.github/codeql/queries/python/tests/NetworkListenerUsage/NetworkListenerUsage.qlref @@ -0,0 +1 @@ +NetworkListenerUsage.ql diff --git a/.github/codeql/queries/python/tests/NetworkListenerUsage/bad.py b/.github/codeql/queries/python/tests/NetworkListenerUsage/bad.py new file mode 100644 index 00000000..8342cedb --- /dev/null +++ b/.github/codeql/queries/python/tests/NetworkListenerUsage/bad.py @@ -0,0 +1 @@ +sock.bind(("127.0.0.1", 8000)) diff --git a/.github/codeql/queries/python/tests/NetworkListenerUsage/good.py b/.github/codeql/queries/python/tests/NetworkListenerUsage/good.py new file mode 100644 index 00000000..ec28b107 --- /dev/null +++ b/.github/codeql/queries/python/tests/NetworkListenerUsage/good.py @@ -0,0 +1 @@ +sock.connect(("example.com", 443)) diff --git a/.github/codeql/queries/python/tests/OsProcessApi/OsProcessApi.expected b/.github/codeql/queries/python/tests/OsProcessApi/OsProcessApi.expected new file mode 100644 index 00000000..31cb3bf0 --- /dev/null +++ b/.github/codeql/queries/python/tests/OsProcessApi/OsProcessApi.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:19 | ControlFlowNode for Attribute() | This os process API bypasses Dispatcharr's guarded subprocess module. | diff --git a/.github/codeql/queries/python/tests/OsProcessApi/OsProcessApi.qlref b/.github/codeql/queries/python/tests/OsProcessApi/OsProcessApi.qlref new file mode 100644 index 00000000..e3ea2253 --- /dev/null +++ b/.github/codeql/queries/python/tests/OsProcessApi/OsProcessApi.qlref @@ -0,0 +1 @@ +OsProcessApi.ql diff --git a/.github/codeql/queries/python/tests/OsProcessApi/bad.py b/.github/codeql/queries/python/tests/OsProcessApi/bad.py new file mode 100644 index 00000000..e2089c0f --- /dev/null +++ b/.github/codeql/queries/python/tests/OsProcessApi/bad.py @@ -0,0 +1,3 @@ +import os as host + +host.system("true") diff --git a/.github/codeql/queries/python/tests/OsProcessApi/good.py b/.github/codeql/queries/python/tests/OsProcessApi/good.py new file mode 100644 index 00000000..a9d32456 --- /dev/null +++ b/.github/codeql/queries/python/tests/OsProcessApi/good.py @@ -0,0 +1,3 @@ +import os + +os.getcwd() diff --git a/.github/codeql/queries/python/tests/OutboundNetworkUsage/OutboundNetworkUsage.expected b/.github/codeql/queries/python/tests/OutboundNetworkUsage/OutboundNetworkUsage.expected new file mode 100644 index 00000000..19cf2c00 --- /dev/null +++ b/.github/codeql/queries/python/tests/OutboundNetworkUsage/OutboundNetworkUsage.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:35 | ControlFlowNode for Attribute() | This outbound network operation requires the `outbound_network` capability. | diff --git a/.github/codeql/queries/python/tests/OutboundNetworkUsage/OutboundNetworkUsage.qlref b/.github/codeql/queries/python/tests/OutboundNetworkUsage/OutboundNetworkUsage.qlref new file mode 100644 index 00000000..12bee8fc --- /dev/null +++ b/.github/codeql/queries/python/tests/OutboundNetworkUsage/OutboundNetworkUsage.qlref @@ -0,0 +1 @@ +OutboundNetworkUsage.ql diff --git a/.github/codeql/queries/python/tests/OutboundNetworkUsage/bad.py b/.github/codeql/queries/python/tests/OutboundNetworkUsage/bad.py new file mode 100644 index 00000000..ba8a4b3a --- /dev/null +++ b/.github/codeql/queries/python/tests/OutboundNetworkUsage/bad.py @@ -0,0 +1,3 @@ +import requests + +requests.get("https://example.com") diff --git a/.github/codeql/queries/python/tests/OutboundNetworkUsage/good.py b/.github/codeql/queries/python/tests/OutboundNetworkUsage/good.py new file mode 100644 index 00000000..839c88d9 --- /dev/null +++ b/.github/codeql/queries/python/tests/OutboundNetworkUsage/good.py @@ -0,0 +1 @@ +requests.Session() diff --git a/.github/codeql/queries/python/tests/PersistentServiceUsage/PersistentServiceUsage.expected b/.github/codeql/queries/python/tests/PersistentServiceUsage/PersistentServiceUsage.expected new file mode 100644 index 00000000..ba7fad5c --- /dev/null +++ b/.github/codeql/queries/python/tests/PersistentServiceUsage/PersistentServiceUsage.expected @@ -0,0 +1 @@ +| bad.py:2:5:2:42 | Function on_leader_acquired | This leader service hook requires the `persistent_service` capability. | diff --git a/.github/codeql/queries/python/tests/PersistentServiceUsage/PersistentServiceUsage.qlref b/.github/codeql/queries/python/tests/PersistentServiceUsage/PersistentServiceUsage.qlref new file mode 100644 index 00000000..c3013fe9 --- /dev/null +++ b/.github/codeql/queries/python/tests/PersistentServiceUsage/PersistentServiceUsage.qlref @@ -0,0 +1 @@ +PersistentServiceUsage.ql diff --git a/.github/codeql/queries/python/tests/PersistentServiceUsage/bad.py b/.github/codeql/queries/python/tests/PersistentServiceUsage/bad.py new file mode 100644 index 00000000..e4e36dd4 --- /dev/null +++ b/.github/codeql/queries/python/tests/PersistentServiceUsage/bad.py @@ -0,0 +1,3 @@ +class Plugin: + def on_leader_acquired(self, context): + pass diff --git a/.github/codeql/queries/python/tests/PersistentServiceUsage/good.py b/.github/codeql/queries/python/tests/PersistentServiceUsage/good.py new file mode 100644 index 00000000..07ebe700 --- /dev/null +++ b/.github/codeql/queries/python/tests/PersistentServiceUsage/good.py @@ -0,0 +1,3 @@ +class Plugin: + def on_leader_lost(self, context): + pass diff --git a/.github/codeql/queries/python/tests/RequestsSessionApi/RequestsSessionApi.expected b/.github/codeql/queries/python/tests/RequestsSessionApi/RequestsSessionApi.expected new file mode 100644 index 00000000..6791d780 --- /dev/null +++ b/.github/codeql/queries/python/tests/RequestsSessionApi/RequestsSessionApi.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:18 | ControlFlowNode for Attribute() | Constructing a requests session bypasses Dispatcharr's guarded module-level HTTP helpers. | diff --git a/.github/codeql/queries/python/tests/RequestsSessionApi/RequestsSessionApi.qlref b/.github/codeql/queries/python/tests/RequestsSessionApi/RequestsSessionApi.qlref new file mode 100644 index 00000000..2e5edcf2 --- /dev/null +++ b/.github/codeql/queries/python/tests/RequestsSessionApi/RequestsSessionApi.qlref @@ -0,0 +1 @@ +RequestsSessionApi.ql diff --git a/.github/codeql/queries/python/tests/RequestsSessionApi/bad.py b/.github/codeql/queries/python/tests/RequestsSessionApi/bad.py new file mode 100644 index 00000000..f9ed8d9c --- /dev/null +++ b/.github/codeql/queries/python/tests/RequestsSessionApi/bad.py @@ -0,0 +1,3 @@ +import requests + +requests.Session() diff --git a/.github/codeql/queries/python/tests/RequestsSessionApi/good.py b/.github/codeql/queries/python/tests/RequestsSessionApi/good.py new file mode 100644 index 00000000..ba8a4b3a --- /dev/null +++ b/.github/codeql/queries/python/tests/RequestsSessionApi/good.py @@ -0,0 +1,3 @@ +import requests + +requests.get("https://example.com") diff --git a/.github/codeql/queries/python/tests/SocketConvenienceApi/SocketConvenienceApi.expected b/.github/codeql/queries/python/tests/SocketConvenienceApi/SocketConvenienceApi.expected new file mode 100644 index 00000000..acb5f3f9 --- /dev/null +++ b/.github/codeql/queries/python/tests/SocketConvenienceApi/SocketConvenienceApi.expected @@ -0,0 +1 @@ +| bad.py:3:1:3:46 | ControlFlowNode for Attribute() | socket.create_connection bypasses Dispatcharr's guarded socket constructor. | diff --git a/.github/codeql/queries/python/tests/SocketConvenienceApi/SocketConvenienceApi.qlref b/.github/codeql/queries/python/tests/SocketConvenienceApi/SocketConvenienceApi.qlref new file mode 100644 index 00000000..7b9e4ba7 --- /dev/null +++ b/.github/codeql/queries/python/tests/SocketConvenienceApi/SocketConvenienceApi.qlref @@ -0,0 +1 @@ +SocketConvenienceApi.ql diff --git a/.github/codeql/queries/python/tests/SocketConvenienceApi/bad.py b/.github/codeql/queries/python/tests/SocketConvenienceApi/bad.py new file mode 100644 index 00000000..21dc7ae2 --- /dev/null +++ b/.github/codeql/queries/python/tests/SocketConvenienceApi/bad.py @@ -0,0 +1,3 @@ +import socket + +socket.create_connection(("example.com", 443)) diff --git a/.github/codeql/queries/python/tests/SocketConvenienceApi/good.py b/.github/codeql/queries/python/tests/SocketConvenienceApi/good.py new file mode 100644 index 00000000..81d14995 --- /dev/null +++ b/.github/codeql/queries/python/tests/SocketConvenienceApi/good.py @@ -0,0 +1,3 @@ +import socket + +socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/.github/codeql/queries/python/tests/SubprocessUsage/SubprocessUsage.expected b/.github/codeql/queries/python/tests/SubprocessUsage/SubprocessUsage.expected new file mode 100644 index 00000000..48d4c95b --- /dev/null +++ b/.github/codeql/queries/python/tests/SubprocessUsage/SubprocessUsage.expected @@ -0,0 +1,2 @@ +| bad.py:3:1:3:26 | Attribute() | This subprocess call requires the `subprocess` capability. | +| bad.py:11:1:11:45 | getattr()() | This subprocess call requires the `subprocess` capability. | diff --git a/.github/codeql/queries/python/tests/SubprocessUsage/SubprocessUsage.qlref b/.github/codeql/queries/python/tests/SubprocessUsage/SubprocessUsage.qlref new file mode 100644 index 00000000..39173a3f --- /dev/null +++ b/.github/codeql/queries/python/tests/SubprocessUsage/SubprocessUsage.qlref @@ -0,0 +1 @@ +SubprocessUsage.ql diff --git a/.github/codeql/queries/python/tests/SubprocessUsage/bad.py b/.github/codeql/queries/python/tests/SubprocessUsage/bad.py new file mode 100644 index 00000000..e1900d52 --- /dev/null +++ b/.github/codeql/queries/python/tests/SubprocessUsage/bad.py @@ -0,0 +1,11 @@ +import subprocess as host_process + +host_process.run(["true"]) + +import importlib + +module_name = "sub" + "process" +member_name = "r" + "un" +load_module = getattr(importlib, "import_module") +loaded_module = load_module(module_name) +getattr(loaded_module, member_name)(["true"]) diff --git a/.github/codeql/queries/python/tests/SubprocessUsage/good.py b/.github/codeql/queries/python/tests/SubprocessUsage/good.py new file mode 100644 index 00000000..2fd88347 --- /dev/null +++ b/.github/codeql/queries/python/tests/SubprocessUsage/good.py @@ -0,0 +1,3 @@ +import subprocess + +subprocess.PIPE diff --git a/.github/codeql/queries/python/tests/SysModulesTamper/SysModulesTamper.expected b/.github/codeql/queries/python/tests/SysModulesTamper/SysModulesTamper.expected new file mode 100644 index 00000000..c98f5e9e --- /dev/null +++ b/.github/codeql/queries/python/tests/SysModulesTamper/SysModulesTamper.expected @@ -0,0 +1,2 @@ +| bad.py:3:1:3:25 | Subscript | Changing this sys.modules entry can bypass Dispatcharr's plugin import wrappers. | +| bad.py:4:1:4:25 | Attribute() | Changing this sys.modules entry can bypass Dispatcharr's plugin import wrappers. | diff --git a/.github/codeql/queries/python/tests/SysModulesTamper/SysModulesTamper.qlref b/.github/codeql/queries/python/tests/SysModulesTamper/SysModulesTamper.qlref new file mode 100644 index 00000000..155b6691 --- /dev/null +++ b/.github/codeql/queries/python/tests/SysModulesTamper/SysModulesTamper.qlref @@ -0,0 +1 @@ +SysModulesTamper.ql diff --git a/.github/codeql/queries/python/tests/SysModulesTamper/bad.py b/.github/codeql/queries/python/tests/SysModulesTamper/bad.py new file mode 100644 index 00000000..8d91731e --- /dev/null +++ b/.github/codeql/queries/python/tests/SysModulesTamper/bad.py @@ -0,0 +1,4 @@ +import sys + +sys.modules["subprocess"] = object() +sys.modules.pop("socket") diff --git a/.github/codeql/queries/python/tests/SysModulesTamper/good.py b/.github/codeql/queries/python/tests/SysModulesTamper/good.py new file mode 100644 index 00000000..b859494a --- /dev/null +++ b/.github/codeql/queries/python/tests/SysModulesTamper/good.py @@ -0,0 +1,4 @@ +import sys + +sys.modules["harmless"] = object() +sys.modules.pop("harmless") diff --git a/.github/codeql/queries/python/tests/codeql-pack.lock.yml b/.github/codeql/queries/python/tests/codeql-pack.lock.yml new file mode 100644 index 00000000..7e8fb941 --- /dev/null +++ b/.github/codeql/queries/python/tests/codeql-pack.lock.yml @@ -0,0 +1,30 @@ +--- +lockVersion: 1.0.0 +dependencies: + codeql/concepts: + version: 0.0.33 + codeql/controlflow: + version: 2.0.43 + codeql/dataflow: + version: 2.1.15 + codeql/mad: + version: 1.0.59 + codeql/python-all: + version: 7.2.7 + codeql/regex: + version: 1.0.59 + codeql/ssa: + version: 2.0.35 + codeql/threat-models: + version: 1.0.59 + codeql/tutorial: + version: 1.0.59 + codeql/typetracking: + version: 2.0.43 + codeql/util: + version: 2.0.46 + codeql/xml: + version: 1.0.59 + codeql/yaml: + version: 1.0.59 +compiled: false diff --git a/.github/codeql/queries/python/tests/qlpack.yml b/.github/codeql/queries/python/tests/qlpack.yml new file mode 100644 index 00000000..b2e4dfd5 --- /dev/null +++ b/.github/codeql/queries/python/tests/qlpack.yml @@ -0,0 +1,5 @@ +name: dispatcharr/plugin-capability-security-query-tests +dependencies: + dispatcharr/plugin-capability-security-queries: "*" +extractor: python +tests: . diff --git a/.github/pluginctl/README.md b/.github/pluginctl/README.md new file mode 100644 index 00000000..04216ee8 --- /dev/null +++ b/.github/pluginctl/README.md @@ -0,0 +1,102 @@ +# pluginctl + +The automation CLI behind the plugin-registry workflows. Every piece of logic +that used to live in inline YAML or `.github/scripts/*.sh` now lives here as a +tested Python package, so the workflows are thin wiring and the behavior is +unit-testable. + +Standard library only. `git`, `gh`, `gpg`, and `clamscan` come from the CI +runner; there is no build step. + +## Layout + +``` +src/pluginctl/ + cli.py argparse dispatch (one subcommand per verb) + feature_flags.py hardcoded, reviewed rollout switches + core/ shared infra: actions, git, gh, jsonio, hashing, timeutil, version + validate/ PR-time pipeline: detect, title, labels, gate, sarif, + langs, clamav, plugin (per-plugin checks), report + publish/ releases pipeline: manifest, zips, cleanup, readmes, run, yank + integrations/ webhooks, external_readme, automerge +tests/ pytest suite (pure logic + golden outputs) +fixtures/ sample SARIF and other test inputs +``` + +## Develop + +```bash +cd .github/pluginctl +python -m pytest # run the suite +``` + +Run a command locally against a real checkout (cwd must be the repo root so +`plugins/` resolves): + +```bash +PYTHONPATH=.github/pluginctl/src python -m pluginctl \ + validate --plugin --author --base-ref main --out /tmp/frag.md +``` + +## Command map (old to new) + +| Old | New | +|---|---| +| `validate/detect-changes.sh` + inline blacklist | `pluginctl detect` | +| inline `validate-title` job | `pluginctl check-title` | +| inline `label-pr` job | `pluginctl label` | +| inline CodeQL SARIF jq (x3) | `pluginctl sarif` | +| inline CodeQL language detection | `pluginctl detect-langs` | +| inline ClamAV status/table | `pluginctl clamav-report` | +| `validate/validate.sh` | `pluginctl validate` | +| `validate/report.sh` | `pluginctl report` | +| inline gate ladder | `pluginctl gate` | +| `publish/run.sh` (+ chained scripts) | `pluginctl publish` | +| `publish/yank-version.sh` | `pluginctl yank` | +| inline `auto-merge-updates` | `pluginctl automerge` | +| inline `update-external-readme` | `pluginctl external-readme` | +| (new) signed events | `pluginctl webhook` | + +## How the workflows wire in + +- `.github/actions/gh-app-token` and `.github/actions/trusted-checkout` are + composite actions (reusable *steps*). +- `.github/workflows/_codeql-scan.yml` and `_clamav-scan.yml` are reusable + *workflows* (whole jobs), called by `validate-plugin.yml`. They must live in + `.github/workflows/` per GitHub; the `_` prefix marks them as internal. +- Every `python -m pluginctl` step sets `PYTHONPATH=.github/pluginctl/src`. In + the CodeQL/ClamAV jobs the package is loaded from a separate base-branch + checkout (`_trusted/`) so fork code from the PR merge tree is never executed. + +## Labels and auto-merge controls + +Run `python -m pluginctl ensure-labels --repo ` to create or update +the labels used by pluginctl. The catalog mirrors the baseline labels from +`Dispatcharr/Plugins` and adds workflow labels such as `Auto Merged`; it does +not change unrelated labels. + +`AUTO_MERGE_UPDATES=true` enables automatic merging of eligible plugin updates. +`AUTO_MERGE_BLACKLIST` is an optional, case-insensitive comma-separated list of +GitHub logins that are excluded from automatic merging only. It does not reject +their PRs. `AUTHOR_BLACKLIST` and `PLUGIN_BLACKLIST` remain validation controls; +all three variables are optional and safely default to an empty list. + +Automatic merges are recorded with the `Auto Merged` label. By default, no +author or plugin may receive more than four automatic merges in a rolling 24 +hours. Set `AUTO_MERGE_AUTHOR_LIMIT`, `AUTO_MERGE_PLUGIN_LIMIT`, or +`AUTO_MERGE_RATE_WINDOW_HOURS` to override this. A limit of zero or less +disables that limit; invalid numeric values skip automatic merging safely. + +## Parity notes for maintainers + +- User-facing output (PR comments, manifests, READMEs) must stay byte-identical + to the old shell. `core/jsonio.py` reproduces `jq -c` exactly, and the + markdown renderers are ported line for line, guarded by golden tests. PR-comment + strings are copied verbatim from the old scripts, so match them exactly rather + than reformatting. +- `publish/readmes.py` intentionally corrects one old bug: deprecated plugins' + version count now comes from the manifest's `versions[]` instead of the + removed `zips/` path (which always rendered 0). +- Publishing defaults to the legacy `root_url` manifest format while the shell + publisher remains in service. `SPLIT_MANIFEST_BASE_URLS` is a reviewed, + hardcoded rollout flag for `download_base_url` and `metadata_base_url`. diff --git a/.github/pluginctl/fixtures/sample.sarif b/.github/pluginctl/fixtures/sample.sarif new file mode 100644 index 00000000..e18cf463 --- /dev/null +++ b/.github/pluginctl/fixtures/sample.sarif @@ -0,0 +1,62 @@ +{ + "runs": [ + { + "tool": { + "driver": { + "rules": [ + {"id": "py/sql-injection", "properties": {"security-severity": "8.8"}}, + {"id": "py/weak-hash", "properties": {"security-severity": "6.5"}}, + {"id": "py/clear-text-logging", "properties": {"security-severity": "5.0"}}, + {"id": "py/unused-import", "properties": {}}, + {"id": "plugin/sandbox-bypass/ctypes-usage", "properties": {}}, + {"id": "plugin/sandbox-bypass/builtins-mutation", "properties": {}} + ] + }, + "extensions": [ + {"rules": [ + {"id": "py/weak-hash", "properties": {"security-severity": "6.9"}} + ]} + ] + }, + "results": [ + { + "ruleId": "py/sql-injection", + "message": {"text": "This SQL query depends on a [user-provided value](123)."}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/main.py"}, "region": {"startLine": 42}}}] + }, + { + "ruleId": "py/weak-hash", + "message": {"text": "Weak hash used at https://example.com/#4 www.foo"}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/util.py"}, "region": {"startLine": 7}}}] + }, + { + "ruleId": "py/clear-text-logging", + "message": {"text": "Sensitive data logged."}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/log.py"}, "region": {"startLine": 3}}}] + }, + { + "ruleId": "py/unused-import", + "message": {"text": "Unused import."}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/x.py"}, "region": {"startLine": 1}}}] + }, + { + "ruleId": "py/sql-injection", + "message": {"text": "Suppressed via inline codeql[py/sql-injection] comment."}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/other.py"}, "region": {"startLine": 9}}}], + "suppressions": [{"kind": "inSource"}] + }, + { + "ruleId": "plugin/sandbox-bypass/ctypes-usage", + "message": {"text": "ctypes can bypass the plugin sandbox."}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/native.py"}, "region": {"startLine": 12}}}] + }, + { + "ruleId": "plugin/sandbox-bypass/builtins-mutation", + "message": {"text": "Suppressed sandbox finding."}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/builtins.py"}, "region": {"startLine": 4}}}], + "suppressions": [{"kind": "inSource"}] + } + ] + } + ] +} diff --git a/.github/pluginctl/pyproject.toml b/.github/pluginctl/pyproject.toml new file mode 100644 index 00000000..3f27a436 --- /dev/null +++ b/.github/pluginctl/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pluginctl" +version = "2.0.0" +description = "Dispatcharr plugin registry automation CLI (validate / publish / yank / webhooks)" +requires-python = ">=3.10" +# Standard-library only. git / gh / gpg / clamscan are provided by the CI runner. +dependencies = [] + +[project.optional-dependencies] +test = ["pytest>=7.0"] + +[project.scripts] +pluginctl = "pluginctl.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["pluginctl*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/.github/pluginctl/src/pluginctl/__init__.py b/.github/pluginctl/src/pluginctl/__init__.py new file mode 100644 index 00000000..4718b949 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/__init__.py @@ -0,0 +1,3 @@ +"""pluginctl - Dispatcharr plugin registry automation CLI (V2).""" + +__version__ = "2.0.0" diff --git a/.github/pluginctl/src/pluginctl/__main__.py b/.github/pluginctl/src/pluginctl/__main__.py new file mode 100644 index 00000000..c1ba5622 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/__main__.py @@ -0,0 +1,6 @@ +"""Enable ``python -m pluginctl``.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/pluginctl/src/pluginctl/capabilities.py b/.github/pluginctl/src/pluginctl/capabilities.py new file mode 100644 index 00000000..6e4c29bb --- /dev/null +++ b/.github/pluginctl/src/pluginctl/capabilities.py @@ -0,0 +1,218 @@ +"""Dispatcharr plugin capability and manifest-schema compatibility policy. + +This intentionally mirrors ``apps.plugins.capabilities`` in Dispatcharr without +depending on Django or plugin loading. Keep capability/version semantics aligned +with Dispatcharr; ``codeql_rules`` is registry-only metadata for this repository. +""" + +from __future__ import annotations + +from typing import Any + + +CAPABILITY_GROUPS: dict[str, dict[str, Any]] = { + "system": {"label": "System", "order": 0}, + "network": {"label": "Network", "order": 1}, + "storage": {"label": "Storage", "order": 2}, + "data": {"label": "Data", "order": 3}, + "other": {"label": "Other", "order": 99}, +} + +KNOWN_CAPABILITIES: dict[str, dict[str, Any]] = { + "background_tasks": { + "group": "system", + "label": "Run background tasks", + "description": "Runs long-running or scheduled work on Dispatcharr's shared background task queue.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": ["plugin/capability-contract/background-tasks"], + }, + "persistent_service": { + "group": "system", + "label": "Run a persistent background service", + "description": "Elected as a cluster-wide leader to run a long-lived service.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": ["plugin/capability-contract/persistent-service"], + }, + "network_listener": { + "group": "network", + "label": "Listen for network connections", + "description": "Binds a socket or web server that accepts inbound network connections.", + "requires_restart": True, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": ["plugin/capability-contract/network-listener"], + }, + "subprocess": { + "group": "system", + "label": "Run host processes", + "description": "Starts commands or processes on the Dispatcharr host.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": ["plugin/capability-contract/subprocess"], + }, + "outbound_network": { + "group": "network", + "label": "Access external network services", + "description": "Connects to remote hosts or makes outbound HTTP requests.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": ["plugin/capability-contract/outbound-network"], + }, + "filesystem_write": { + "group": "storage", + "label": "Write outside plugin storage", + "description": "Writes files outside the plugin's code and persistent data directories.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": [], + }, + "celery_dispatch": { + "group": "system", + "label": "Dispatch approved internal tasks", + "description": "Queues explicitly approved Dispatcharr background tasks.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": True, + "codeql_rules": ["plugin/capability-contract/celery-dispatch"], + }, + "proxy_internals": { + "group": "data", + "label": "Use live proxy internals", + "description": "Uses Dispatcharr live-stream proxy implementation details.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": False, + "codeql_rules": [], + }, + "user_data": { + "group": "data", + "label": "Access user account data", + "description": "Reads Dispatcharr user account data.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": False, + "codeql_rules": [], + }, + "external_dependencies": { + "group": "system", + "label": "Install external Python dependencies", + "description": "Installs declared third-party Python packages for this plugin.", + "requires_restart": False, + "min_manifest_version": 1, + "max_manifest_version": None, + "runtime_enforced": False, + "codeql_rules": [], + }, +} + +MANIFEST_SCHEMA_POLICIES: dict[int, dict[str, Any]] = { + 0: { + "min_app_version": None, + "max_app_version": None, + "parses_capabilities": False, + "enforces_sandbox": False, + }, + 1: { + "min_app_version": None, + "max_app_version": None, + "parses_capabilities": True, + "enforces_sandbox": False, + }, + 2: { + "min_app_version": None, + "max_app_version": None, + "parses_capabilities": True, + "enforces_sandbox": True, + }, +} + + +def parse_manifest_version(manifest: dict[str, Any]) -> int: + try: + value = int(manifest.get("manifest_version", 0)) + except (TypeError, ValueError): + return 0 + return value if value >= 0 else 0 + + +def manifest_version_is_valid(manifest: dict[str, Any]) -> bool: + """Whether an explicitly supplied manifest version is a non-negative int. + + Dispatcharr currently treats malformed values as legacy for compatibility. + The registry scanner is stricter: explicit malformed values must receive + manual review instead of suppressing a capability-contract signal. + """ + if "manifest_version" not in manifest: + return True + value = manifest["manifest_version"] + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def manifest_schema_policy(manifest_version: int) -> dict[str, Any]: + return MANIFEST_SCHEMA_POLICIES.get( + manifest_version, MANIFEST_SCHEMA_POLICIES[max(MANIFEST_SCHEMA_POLICIES)] + ) + + +def manifest_version_parses_capabilities(manifest_version: int) -> bool: + return manifest_schema_policy(manifest_version)["parses_capabilities"] + + +def manifest_version_enforces_sandbox(manifest_version: int) -> bool: + return manifest_schema_policy(manifest_version)["enforces_sandbox"] + + +def first_enforcing_manifest_version() -> int: + return min( + version for version, policy in MANIFEST_SCHEMA_POLICIES.items() + if policy["enforces_sandbox"] + ) + + +def capability_supported_by_manifest_version(capability_id: str, manifest_version: int) -> bool: + if not manifest_version_parses_capabilities(manifest_version): + return False + policy = KNOWN_CAPABILITIES.get(capability_id) + if policy is None: + return True + minimum = policy["min_manifest_version"] + maximum = policy["max_manifest_version"] + return (minimum is None or manifest_version >= minimum) and ( + maximum is None or manifest_version <= maximum + ) + + +def declared_capabilities(manifest: dict[str, Any]) -> set[str]: + return effective_capabilities(manifest) + + +def effective_capabilities(manifest: dict[str, Any]) -> set[str]: + """Mirror Dispatcharr's explicit and async-action capability inference.""" + version = parse_manifest_version(manifest) + values = manifest.get("capabilities") + declared = { + value for value in values + if isinstance(value, str) and capability_supported_by_manifest_version(value, version) + } if isinstance(values, list) else set() + actions = manifest.get("actions") + if isinstance(actions, list) and any( + isinstance(action, dict) and action.get("async") for action in actions + ) and capability_supported_by_manifest_version("background_tasks", version): + declared.add("background_tasks") + return declared diff --git a/.github/pluginctl/src/pluginctl/cli.py b/.github/pluginctl/src/pluginctl/cli.py new file mode 100644 index 00000000..6db37ef9 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/cli.py @@ -0,0 +1,475 @@ +"""argparse dispatch for ``pluginctl`` / ``python -m pluginctl``. + +Each subcommand is a thin wrapper that reads flags (and falls back to the same +environment variables the workflows already set) and delegates to a handler +module. Handlers are imported lazily so a single missing optional dependency or +runtime tool never breaks unrelated commands. +""" + +from __future__ import annotations + +import argparse +import json +import os +from typing import Optional + + +def _matrix(value: Optional[str]) -> list[str]: + if not value: + return [] + try: + parsed = json.loads(value) + return [str(x) for x in parsed] if isinstance(parsed, list) else [] + except json.JSONDecodeError: + return [] + + +def _bool(value: Optional[str]) -> bool: + return str(value).lower() == "true" + + +def _env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +def _repo(args) -> str: + """``--repo``, falling back to the repository the workflow runs in.""" + return args.repo or _env("GITHUB_REPOSITORY") + + +# --------------------------------------------------------------------------- # +# Handlers +# --------------------------------------------------------------------------- # +def cmd_detect(args) -> int: + from .validate import detect + return detect.run( + pr_author=args.author, + base_ref=args.base_ref, + head_ref=args.head_ref or "", + author_blacklist=_env("AUTHOR_BLACKLIST"), + plugin_blacklist=_env("PLUGIN_BLACKLIST"), + repo=_repo(args), + ) + + +def cmd_check_title(args) -> int: + from .core import actions + from .validate import title + result = title.check_title(args.title, args.author, args.plugin_count, _matrix(args.matrix)) + actions.set_output("title_valid", "true" if result.valid else "false") + actions.set_output("title_feedback", result.feedback) + actions.set_output("title_suggestion", result.suggestion) + if not result.valid: + actions.error("PR title does not match the required format. See CONTRIBUTING.md for details.") + return 1 + return 0 + + +def cmd_label(args) -> int: + from .validate import labels + return labels.run( + pr_number=args.pr, + has_new_plugin=_bool(args.has_new_plugin), + has_updated_plugin=_bool(args.has_updated_plugin), + outside_files=args.outside_files or "", + outside_violation=_bool(args.outside_violation), + close_pr=_bool(args.close_pr), + ) + + +def cmd_gate(args) -> int: + from .core import actions + from .validate import gate + result = gate.evaluate( + detect_result=_env("DETECT_RESULT"), + close_pr=_env("CLOSE_PR"), + skip_validation=_env("SKIP_VALIDATION"), + outside_violation=_env("OUTSIDE_VIOLATION"), + title_result=_env("TITLE_RESULT"), + codeql_result=_env("CODEQL_RESULT"), + codeql_status=_env("CODEQL_STATUS"), + clamav_result=_env("CLAMAV_RESULT"), + clamav_status=_env("CLAMAV_STATUS"), + validate_result=_env("VALIDATE_RESULT"), + report_result=_env("REPORT_RESULT"), + test_result=_env("TEST_RESULT", "skipped"), + ) + if result.ok: + actions.log(result.message) + return 0 + actions.error(result.message) + return 1 + + +def cmd_sarif(args) -> int: + from .validate import sarif + return sarif.run( + results_dir=args.results_dir, + repo=_repo(args), + sha=args.sha, + matrix=_matrix(args.matrix), + analyze_outcome=args.analyze_outcome, + languages_found=_bool(args.found), + languages=args.languages or "", + unscanned_langs=args.unscanned_langs or "", + job_status=_env("CODEQL_ACTION_JOB_STATUS"), + ) + + +def cmd_validate(args) -> int: + from .validate import plugin as validate + return validate.run( + plugin_name=args.plugin, + pr_author=args.author, + base_ref=args.base_ref, + output_file=args.out, + repo=_repo(args), + ) + + +def cmd_report(args) -> int: + from .validate import report + return report.run( + pr_number=args.pr, + pr_author=args.author, + plugin_count=args.plugin_count, + close_pr=_bool(args.close_pr), + fragments_dir=args.fragments_dir, + repo=_repo(args), + ) + + +def cmd_detect_langs(args) -> int: + from .validate import langs + return langs.run(root=args.root) + + +def cmd_clamav_report(args) -> int: + from .validate import clamav + return clamav.run(output_file=args.output, scan_exit=args.scan_exit, + findings_out=args.findings_out) + + +def cmd_webhook(args) -> int: + from .integrations import webhooks + data = json.loads(args.data) if args.data else {} + webhooks.emit(args.event, data) + return 0 # emission failures never fail the pipeline + + +def cmd_discord_webhook(args) -> int: + from .integrations import webhooks + repository = _repo(args) + events = sorted(webhooks.SUPPORTED_DISCORD_EVENTS) if args.event == "all" else [args.event] + if args.data and args.event == "all": + raise ValueError("--data cannot be combined with --event all") + for event in events: + data = json.loads(args.data) if args.data else _discord_fixture(event) + if args.random: + repository = "Dispatcharr/Plugins" + data = _random_discord_fixture(event, repository) + payload = webhooks.discord_payload(event, data, repository) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if not args.send: + continue + temporary_attachment = "" + temporary_directory = "" + if data.get("attachment_url") and not data.get("attachment_path"): + import tempfile + import urllib.request + temporary_directory = tempfile.mkdtemp(prefix="pluginctl-discord-") + temporary_attachment = os.path.join(temporary_directory, data["attachment_filename"]) + with urllib.request.urlopen(data["attachment_url"], timeout=30) as response: + with open(temporary_attachment, "wb") as fh: + fh.write(response.read()) + data["attachment_path"] = temporary_attachment + try: + webhooks.emit_discord(event, data, repository=repository, url=args.webhook_url or None) + finally: + if temporary_attachment: + os.unlink(temporary_attachment) + if temporary_directory: + os.rmdir(temporary_directory) + return 0 + + +def _discord_fixture(event: str) -> dict: + fixtures = { + "plugin.published": {"plugin": "example-plugin", "version": "1.2.3", "release_url": "https://github.com/example/plugins/releases"}, + "pr.auto_merged": {"pr": "42", "author": "octocat", "plugins": ["example-plugin"]}, + "pr.review_required": {"pr": "42", "author": "octocat", "plugins": ["example-plugin"], "reason": "Manual review is required."}, + } + return fixtures[event] + + +def _random_discord_fixture(event: str, repo: str) -> dict: + """Use a real, logo-bearing Dispatcharr/Plugins candidate for simulation.""" + import base64 + import random + from .core import gh + + def logo_url(plugin: str, ref: str) -> str: + path = logos.get(plugin) + return f"https://raw.githubusercontent.com/{repo}/main/{path}" if path else "" + + tree = gh.loads(gh.api(f"repos/{repo}/git/trees/main?recursive=1")) or {} + logos = {} + for entry in tree.get("tree", []): + path = entry.get("path", "") + if path.startswith("plugins/") and path.rsplit("/", 1)[-1] in { + "logo.png", "logo.svg", "logo.jpg", "logo.webp", + }: + logos.setdefault(path.split("/")[1], path) + + if event == "plugin.published": + plugins = sorted(logos) + if not plugins: + raise ValueError("No logo-bearing plugins found in Dispatcharr/Plugins.") + from .core import version as version_util + published = [(candidate, version_util.versioned_tags(gh.release_list_tags(repo), candidate)) + for candidate in plugins] + published = [(candidate, tags) for candidate, tags in published if tags] + if not published: + raise ValueError("No published releases found for logo-bearing plugins.") + plugin, release_tags = random.choice(published) + manifest = gh.loads(gh.api(f"repos/{repo}/contents/plugins/{plugin}/plugin.json?ref=main")) or {} + raw = json.loads(base64.b64decode(manifest["content"]).decode("utf-8")) + tag = release_tags[0] + version = tag[len(plugin) + 1:] + asset_url = f"https://github.com/{repo}/releases/download/{tag}/{tag}.zip" + links = [] + upstream = str(raw.get("repo_url") or "").rstrip("/") + external_upstream = raw.get("source_type") == "external" and upstream.startswith("https://github.com/") + if not external_upstream: + upstream = f"https://github.com/{repo}" + source_tag = f"v{version}" if external_upstream else tag + if external_upstream: + links.append(f"[v{version}]({upstream}/releases/tag/{source_tag})") + previous = [entry for entry in release_tags if entry != tag] + if previous: + previous_tag = previous[0] + previous_version = previous_tag[len(plugin) + 1:] + if external_upstream: + links.append(f"[v{previous_version}...v{version}]({upstream}/compare/v{previous_version}...v{version})") + release_manifest = gh.loads(gh.api( + f"repos/{repo}/contents/metadata/{plugin}/manifest.json?ref=releases" + )) or {} + try: + release_data = json.loads(base64.b64decode(release_manifest["content"]).decode("utf-8")) + versions = (release_data.get("manifest") or {}).get("versions") or [] + source_sha = next((str(entry.get("commit_sha") or "") for entry in versions + if str(entry.get("version")) == version), "") + if not external_upstream and source_sha: + links.append(f"[{source_sha[:7]}]({upstream}/commit/{source_sha})") + except (KeyError, ValueError, TypeError): + source_sha = "" + pr_number, pr_url = gh.commit_pull_number(repo, source_sha) if source_sha else (None, None) + if pr_number and pr_url: + links.append(f"[#{pr_number}]({pr_url})") + return {"plugin": plugin, "version": version, "description": str(raw.get("description") or ""), + "author": str(raw.get("author") or "unknown"), + "logo_url": logo_url(plugin, "main"), + "release_url": f"https://github.com/{repo}/releases/tag/{tag}", + "metadata": "-# " + " · ".join(links), + "attachment_filename": f"{tag}.zip", "attachment_url": asset_url} + + fields = "number,author,url,headRefOid,mergedAt,labels" + state = "closed" if event == "pr.auto_merged" else "open" + prs = gh.loads(gh.pr_list(repo, state, fields, limit=100)) or [] + def merged_plugin_prs(candidates: list[dict]) -> list[dict]: + return [pr for pr in candidates if pr.get("mergedAt") and any( + label.get("name") in {"New Plugin", "Plugin Update"} + for label in (pr.get("labels") or []) + )] + if event == "pr.auto_merged": + prs = merged_plugin_prs(prs) + elif event == "pr.review_required": + # Prefer a live review candidate. If none has a logo, include real merged + # plugin PRs so --random remains useful for Components V2 layout testing. + prs += merged_plugin_prs(gh.loads(gh.pr_list(repo, "closed", fields, limit=100)) or []) + random.shuffle(prs) + candidates = [] + for pr in prs[:25]: + paths = gh.pr_files(str(pr["number"]), repo) or [] + plugins = sorted({path.split("/")[1] for path in paths if path.startswith("plugins/") + and path.count("/") >= 2 and path.split("/")[1] in logos}) + for plugin in plugins: + logo = logo_url(plugin, pr.get("headRefOid", "")) + if logo: + candidates.append((pr, plugin, logo)) + break + if not candidates: + raise ValueError(f"No {state} logo-bearing plugin PRs found in Dispatcharr/Plugins.") + pr, plugin, logo = random.choice(candidates) + raw_plugin = gh.loads(gh.api( + f"repos/{repo}/contents/plugins/{plugin}/plugin.json?ref={pr.get('headRefOid', '')}" + )) or {} + try: + plugin_data = json.loads(base64.b64decode(raw_plugin["content"]).decode("utf-8")) + except (KeyError, ValueError, TypeError): + plugin_data = {} + upstream = str(plugin_data.get("repo_url") or "").rstrip("/") + current_version = str(plugin_data.get("version") or "") + links = [] + if current_version: + external_upstream = plugin_data.get("source_type") == "external" and upstream.startswith("https://github.com/") + if not external_upstream: + upstream = f"https://github.com/{repo}" + current_tag = f"v{current_version}" if external_upstream else f"{plugin}-{current_version}" + if external_upstream: + links.append(f"[v{current_version}]({upstream}/releases/tag/{current_tag})") + pr_detail = gh.loads(gh.api(f"repos/{repo}/pulls/{pr['number']}")) or {} + base_sha = ((pr_detail.get("base") or {}).get("sha") or "") + base_plugin = gh.loads(gh.api( + f"repos/{repo}/contents/plugins/{plugin}/plugin.json?ref={base_sha}" + )) or {} + try: + base_data = json.loads(base64.b64decode(base_plugin["content"]).decode("utf-8")) + previous_version = str(base_data.get("version") or "") + except (KeyError, ValueError, TypeError): + previous_version = "" + if previous_version and previous_version != current_version: + previous_tag = f"v{previous_version}" if external_upstream else f"{plugin}-{previous_version}" + links.append(f"[v{previous_version}...v{current_version}]({upstream}/compare/{previous_tag}...{current_tag})") + return {"pr": str(pr["number"]), "author": str(plugin_data.get("author") or "unknown"), + "pr_url": pr.get("url", ""), "plugins": [plugin], "plugin_name": plugin_data.get("name") or plugin, + "description": str(plugin_data.get("description") or ""), + "source_links": "-# " + " · ".join(links), "logo_url": logo} + + +def cmd_publish(args) -> int: + from .publish import run as publish_run + return publish_run.run(source_branch=args.source_branch) + + +def cmd_yank(args) -> int: + from .publish import yank + return yank.run() + + +def cmd_automerge(args) -> int: + from .integrations import automerge + return automerge.run(head_sha=args.head_sha or _env("HEAD_SHA"), disabled_reason=args.disabled_reason) + + +def cmd_ensure_labels(args) -> int: + from . import label_catalog + return 0 if label_catalog.ensure(_repo(args)) else 1 + + +def cmd_external_readme(args) -> int: + from .integrations import external_readme + return external_readme.run() + + +# --------------------------------------------------------------------------- # +# Parser +# --------------------------------------------------------------------------- # +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="pluginctl", description="Dispatcharr plugin registry automation") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("detect", help="Detect changed plugins and build the matrix") + p.add_argument("--author", required=True) + p.add_argument("--base-ref", required=True) + p.add_argument("--head-ref", default="") + p.add_argument("--repo", default="") + p.set_defaults(func=cmd_detect) + + p = sub.add_parser("check-title", help="Validate the PR title format") + p.add_argument("--title", required=True) + p.add_argument("--author", required=True) + p.add_argument("--plugin-count", type=int, default=0) + p.add_argument("--matrix", default="[]") + p.set_defaults(func=cmd_check_title) + + p = sub.add_parser("label", help="Reconcile PR classification labels") + p.add_argument("--pr", required=True) + p.add_argument("--has-new-plugin", default="false") + p.add_argument("--has-updated-plugin", default="false") + p.add_argument("--outside-files", default="") + p.add_argument("--outside-violation", default="false") + p.add_argument("--close-pr", default="false") + p.set_defaults(func=cmd_label) + + p = sub.add_parser("gate", help="Evaluate the Plugin PR Check gate (env-driven)") + p.set_defaults(func=cmd_gate) + + p = sub.add_parser("sarif", help="Parse CodeQL SARIF: counts, tables, status") + p.add_argument("--results-dir", default="sarif-results") + p.add_argument("--repo", default="") + p.add_argument("--sha", default="") + p.add_argument("--matrix", default="[]") + p.add_argument("--analyze-outcome", default="") + p.add_argument("--found", default="false") + p.add_argument("--languages", default="") + p.add_argument("--unscanned-langs", default="") + p.set_defaults(func=cmd_sarif) + + p = sub.add_parser("validate", help="Validate one plugin -> markdown fragment") + p.add_argument("--plugin", required=True) + p.add_argument("--author", required=True) + p.add_argument("--base-ref", required=True) + p.add_argument("--out", required=True) + p.add_argument("--repo", default="") + p.set_defaults(func=cmd_validate) + + p = sub.add_parser("report", help="Aggregate fragments, post PR comment") + p.add_argument("--pr", required=True) + p.add_argument("--author", required=True) + p.add_argument("--plugin-count", required=True) + p.add_argument("--close-pr", default="false") + p.add_argument("--fragments-dir", default=".") + p.add_argument("--repo", default="") + p.set_defaults(func=cmd_report) + + p = sub.add_parser("detect-langs", help="Detect CodeQL languages in changed plugins") + p.add_argument("--root", default="plugins") + p.set_defaults(func=cmd_detect_langs) + + p = sub.add_parser("clamav-report", help="Parse clamscan output: status + findings table") + p.add_argument("--output", default="clamav-output.txt") + p.add_argument("--scan-exit", type=int, default=0) + p.add_argument("--findings-out", default="clamav-findings.md") + p.set_defaults(func=cmd_clamav_report) + + p = sub.add_parser("webhook", help="Emit a signed webhook event") + p.add_argument("--event", required=True) + p.add_argument("--data", default="{}") + p.set_defaults(func=cmd_webhook) + + p = sub.add_parser("discord-webhook", help="Preview or send a Discord Components V2 event") + p.add_argument("--event", required=True, choices=["all", "plugin.published", "pr.auto_merged", "pr.review_required"]) + p.add_argument("--data", default="") + p.add_argument("--repo", default="") + p.add_argument("--random", action="store_true") + p.add_argument("--send", action="store_true") + p.add_argument("--webhook-url", default="") + p.set_defaults(func=cmd_discord_webhook) + + p = sub.add_parser("publish", help="Publish plugins to the releases branch") + p.add_argument("source_branch") + p.set_defaults(func=cmd_publish) + + p = sub.add_parser("yank", help="Yank a plugin version (env-driven)") + p.set_defaults(func=cmd_yank) + + p = sub.add_parser("automerge", help="Auto-merge a pure plugin-update PR") + p.add_argument("--head-sha", default="") + p.add_argument("--disabled-reason", default="") + p.set_defaults(func=cmd_automerge) + + p = sub.add_parser("ensure-labels", help="Create or update labels required by pluginctl") + p.add_argument("--repo", default="") + p.set_defaults(func=cmd_ensure_labels) + + p = sub.add_parser("external-readme", help="Open/update the external README PR (env-driven)") + p.set_defaults(func=cmd_external_readme) + + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) diff --git a/.github/pluginctl/src/pluginctl/core/__init__.py b/.github/pluginctl/src/pluginctl/core/__init__.py new file mode 100644 index 00000000..b9f67b6a --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/__init__.py @@ -0,0 +1 @@ +"""Shared infrastructure: JSON, versioning, git/gh wrappers, Actions plumbing.""" diff --git a/.github/pluginctl/src/pluginctl/core/actions.py b/.github/pluginctl/src/pluginctl/core/actions.py new file mode 100644 index 00000000..6e5d241d --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/actions.py @@ -0,0 +1,73 @@ +"""GitHub Actions plumbing: GITHUB_OUTPUT, step summary, workflow commands. + +Thin, side-effecting helpers that write to the files/streams the runner reads. +Kept tiny so command modules stay testable (they build values; these emit them). +""" + +from __future__ import annotations + +import os +import sys +import uuid + + +def _format_output(name: str, value: str) -> str: + """One ``$GITHUB_OUTPUT`` entry; multiline values use GitHub's heredoc form + with a random delimiter to avoid collisions with the payload.""" + if "\n" in value: + delim = f"ghadelim_{uuid.uuid4().hex}" + return f"{name}<<{delim}\n{value}\n{delim}\n" + return f"{name}={value}\n" + + +def set_output(name: str, value: str) -> None: + """Append ``name=value`` to ``$GITHUB_OUTPUT`` (multiline-safe).""" + path = os.environ.get("GITHUB_OUTPUT") + if not path: + # Local/dry-run: echo to stdout the same way the shell scripts did. + print(f"{name}={value}") + return + with open(path, "a", encoding="utf-8") as fh: + fh.write(_format_output(name, value)) + + +def set_outputs(**kwargs: str) -> None: + values = {name: "" if value is None else str(value) for name, value in kwargs.items()} + path = os.environ.get("GITHUB_OUTPUT") + if not path: + for name, value in values.items(): + print(f"{name}={value}") + return + with open(path, "a", encoding="utf-8") as fh: + fh.write("".join(_format_output(name, value) for name, value in values.items())) + + +def step_summary(markdown: str) -> None: + path = os.environ.get("GITHUB_STEP_SUMMARY") + if not path: + print(markdown) + return + with open(path, "a", encoding="utf-8") as fh: + fh.write(markdown) + if not markdown.endswith("\n"): + fh.write("\n") + + +def error(message: str) -> None: + print(f"::error::{message}") + + +def warning(message: str) -> None: + print(f"::warning::{message}") + + +def notice(message: str) -> None: + print(f"::notice::{message}") + + +def log(message: str) -> None: + print(message) + + +def eprint(message: str) -> None: + print(message, file=sys.stderr) diff --git a/.github/pluginctl/src/pluginctl/core/gh.py b/.github/pluginctl/src/pluginctl/core/gh.py new file mode 100644 index 00000000..bb9cea69 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/gh.py @@ -0,0 +1,201 @@ +"""Thin wrapper over the ``gh`` CLI and the GitHub REST/GraphQL surface we use. + +Every call shells out to ``gh`` (already authenticated on the runner via +``GH_TOKEN``), matching how the V1 scripts invoked it. Helpers return parsed +values and swallow errors the same way the ``|| true`` / ``2>/dev/null`` idioms +did in Bash, so callers keep the same permissive behavior. +""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any, Optional + + +def _run(args: list[str], check: bool = False, input_text: Optional[str] = None) -> subprocess.CompletedProcess: + return subprocess.run( + ["gh", *args], + check=check, + capture_output=True, + text=True, + input=input_text, + ) + + +def api(endpoint: str, jq: Optional[str] = None, method: Optional[str] = None, + fields: Optional[dict[str, str]] = None) -> Optional[str]: + """``gh api `` returning stripped stdout, or None on failure.""" + args = ["api", endpoint] + if method: + args += ["-X", method] + if jq: + args += ["--jq", jq] + for k, v in (fields or {}).items(): + args += ["-f", f"{k}={v}"] + proc = _run(args) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def collaborator_permission(owner: str, repo: str, user: str) -> str: + """Return the collaborator permission string, or ``none`` (matches Bash).""" + perm = api(f"repos/{owner}/{repo}/collaborators/{user}/permission", + jq=".permission") + return perm if perm else "none" + + +def has_write_access(owner: str, repo: str, user: str) -> bool: + """True when the user has admin/maintain/write, mirroring has_write_access().""" + return collaborator_permission(owner, repo, user) in ("admin", "maintain", "write") + + +def graphql(query: str, f_fields: Optional[dict[str, str]] = None, + F_fields: Optional[dict[str, str]] = None, jq: Optional[str] = None) -> Optional[str]: + args = ["api", "graphql", "-f", f"query={query}"] + for k, v in (f_fields or {}).items(): + args += ["-f", f"{k}={v}"] + for k, v in (F_fields or {}).items(): + args += ["-F", f"{k}={v}"] + if jq: + args += ["--jq", jq] + proc = _run(args) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def pr_comment(pr_number: str, body: str) -> int: + """``gh pr comment``: returns the exit code (report.sh keys off it).""" + return _run(["pr", "comment", str(pr_number), "--body", body]).returncode + + +def pr_close(pr_number: str) -> int: + return _run(["pr", "close", str(pr_number)]).returncode + + +def pr_comment_repo(pr_number: str, repo: str, body: str) -> int: + return _run(["pr", "comment", str(pr_number), "--repo", repo, "--body", body]).returncode + + +def pr_edit_label(pr_number: str, label: str, add: bool) -> int: + flag = "--add-label" if add else "--remove-label" + return _run(["pr", "edit", str(pr_number), flag, label]).returncode + + +def label_create(name: str, color: str, description: str, repo: str) -> int: + """Create or reconcile a label without affecting unrelated repository labels.""" + return _run(["label", "create", name, "--color", color, + "--description", description, "--force", "--repo", repo]).returncode + + +def pr_view_json(pr_number: str, fields: str, jq: Optional[str] = None, + repo: Optional[str] = None) -> Optional[str]: + args = ["pr", "view", str(pr_number), "--json", fields] + if jq: + args += ["--jq", jq] + if repo: + args += ["--repo", repo] + proc = _run(args) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def loads(text: Optional[str]) -> Any: + if not text: + return None + try: + return json.loads(text) + except json.JSONDecodeError: + return None + + +# ---- GitHub Releases helpers (publish / yank / cleanup) --------------------- +def release_exists(tag: str, repo: str) -> bool: + return _run(["release", "view", tag, "--repo", repo]).returncode == 0 + + +def release_list_tags(repo: str, limit: int = 500) -> list[str]: + proc = _run(["release", "list", "--repo", repo, "--json", "tagName", "--limit", str(limit)]) + if proc.returncode != 0: + return [] + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError: + return [] + return [entry.get("tagName", "") for entry in data if entry.get("tagName")] + + +def release_create(tag: str, repo: str, title: str, notes: str, asset: str) -> int: + return _run(["release", "create", tag, "--repo", repo, "--title", title, + "--notes", notes, asset]).returncode + + +def pages_url(repo: str) -> str: + """GitHub Pages site URL for the repo, or an empty string when unavailable.""" + return api(f"repos/{repo}/pages", jq=".html_url // empty") or "" + + +def release_delete(tag: str, repo: str, cleanup_tag: bool = True) -> int: + args = ["release", "delete", tag, "--repo", repo, "--yes"] + if cleanup_tag: + args.append("--cleanup-tag") + return _run(args).returncode + + +def pr_list(repo: str, state: str, fields: str, jq: Optional[str] = None, + search: Optional[str] = None, limit: int = 30) -> Optional[str]: + args = ["pr", "list", "--repo", repo, "--state", state, "--json", fields] + if search: + args += ["--search", search] + args += ["--limit", str(limit)] + if jq: + args += ["--jq", jq] + proc = _run(args) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def pr_files(pr_number: str, repo: str) -> Optional[list[str]]: + """Return changed paths for a PR, or ``None`` when GitHub cannot provide them.""" + data = loads(api(f"repos/{repo}/pulls/{pr_number}/files?per_page=100")) + if not isinstance(data, list): + return None + return [entry["filename"] for entry in data if isinstance(entry, dict) and entry.get("filename")] + + +def pr_merge_squash(pr: str, repo: str, delete_branch: bool = False) -> int: + return _run(["pr", "merge", str(pr), "--repo", repo, "--squash", + f"--delete-branch={str(delete_branch).lower()}"]).returncode + + +def issue_comment(issue: str, repo: str, body: str) -> int: + return _run(["issue", "comment", str(issue), "--repo", repo, "--body", body]).returncode + + +def issue_close(issue: str, repo: str, reason: str = "completed") -> int: + return _run(["issue", "close", str(issue), "--repo", repo, "--reason", reason]).returncode + + +def pr_create(repo: str, base: str, head: str, title: str, body: str, + labels: Optional[list[str]] = None) -> Optional[str]: + args = ["pr", "create", "--repo", repo, "--base", base, "--head", head, + "--title", title, "--body", body] + for label in (labels or []): + args += ["--label", label] + proc = _run(args) + if proc.returncode != 0: + return None + return proc.stdout.strip() + + +def commit_pull_number(repo: str, sha: str) -> tuple[Optional[str], Optional[str]]: + """The first PR associated with a commit, as (number, html_url) or (None, None).""" + out = api(f"repos/{repo}/commits/{sha}/pulls", + jq='.[0] | {number: .number, url: .html_url}') + data = loads(out) or {} + number = data.get("number") + return (str(number) if number else None, data.get("url")) diff --git a/.github/pluginctl/src/pluginctl/core/git.py b/.github/pluginctl/src/pluginctl/core/git.py new file mode 100644 index 00000000..9c8f233b --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/git.py @@ -0,0 +1,95 @@ +"""Thin wrappers over the local ``git`` CLI used by detect / validate / publish.""" + +from __future__ import annotations + +import subprocess +from typing import Optional + + +def run(*args: str, check: bool = True) -> subprocess.CompletedProcess: + """``git `` with output captured as text.""" + return subprocess.run( + ["git", *args], + check=check, + capture_output=True, + text=True, + ) + + +def configure_identity(app_slug: str) -> None: + """Set the committer identity to the GitHub App bot, else github-actions[bot].""" + if not app_slug: + run("config", "user.name", "github-actions[bot]") + run("config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") + return + from . import gh + bot_user_id = gh.api(f"/users/{app_slug}%5Bbot%5D", jq=".id") or "" + run("config", "user.name", f"{app_slug}[bot]") + email = (f"{bot_user_id}+{app_slug}[bot]@users.noreply.github.com" if bot_user_id + else f"{app_slug}[bot]@users.noreply.github.com") + run("config", "user.email", email) + + +def merge_base(a: str, b: str) -> str: + return run("merge-base", a, b).stdout.strip() + + +def diff_name_only(base: str, head: str = "HEAD", paths: Optional[list[str]] = None) -> list[str]: + args = ["diff", "--name-only", base, head] + if paths: + args += ["--", *paths] + out = run(*args).stdout + return [line for line in out.splitlines() if line] + + +def diff_name_only_range(range_spec: str, paths: Optional[list[str]] = None) -> list[str]: + """``git diff --name-only [-- paths]`` where range is e.g. ``a...HEAD``.""" + args = ["diff", "--name-only", range_spec] + if paths: + args += ["--", *paths] + out = run(*args).stdout + return [line for line in out.splitlines() if line] + + +def show(ref_path: str) -> Optional[str]: + """``git show :`` returning None when the object does not exist.""" + proc = run("show", ref_path, check=False) + if proc.returncode != 0: + return None + return proc.stdout + + +def object_exists(ref_path: str) -> bool: + return run("show", ref_path, check=False).returncode == 0 + + +def log_format(fmt: str, ref: str, path: Optional[str] = None) -> Optional[str]: + """``git log -1 --format= [-- ]`` (None if empty/failure).""" + args = ["log", "-1", f"--format={fmt}", ref] + if path is not None: + args += ["--", path] + proc = run(*args, check=False) + if proc.returncode != 0: + return None + out = proc.stdout.strip() + return out or None + + +def rev_parse(rev: str, short: bool = False) -> str: + args = ["rev-parse"] + if short: + args.append("--short") + args.append(rev) + return run(*args).stdout.strip() + + +def fetch(*args: str) -> None: + run("fetch", *args, check=False) + + +def sparse_checkout_add(path: str) -> bool: + return run("sparse-checkout", "add", path, check=False).returncode == 0 + + +def checkout(*args: str) -> bool: + return run("checkout", *args, check=False).returncode == 0 diff --git a/.github/pluginctl/src/pluginctl/core/hashing.py b/.github/pluginctl/src/pluginctl/core/hashing.py new file mode 100644 index 00000000..98e6a50c --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/hashing.py @@ -0,0 +1,17 @@ +"""Chunked file hashing shared by the ZIP builder and the ClamAV report.""" + +from __future__ import annotations + +import hashlib + +CHUNK_SIZE = 1 << 20 + + +def file_digests(path: str, *algorithms: str) -> list[str]: + """Hex digests of ``path`` for each named algorithm, reading the file once.""" + hashers = [hashlib.new(name) for name in algorithms] + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(CHUNK_SIZE), b""): + for hasher in hashers: + hasher.update(chunk) + return [hasher.hexdigest() for hasher in hashers] diff --git a/.github/pluginctl/src/pluginctl/core/jsonio.py b/.github/pluginctl/src/pluginctl/core/jsonio.py new file mode 100644 index 00000000..037797d6 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/jsonio.py @@ -0,0 +1,42 @@ +"""Canonical JSON helpers for manifest payloads and on-disk wrappers. + +Manifest objects are built as insertion-ordered ``dict`` objects in the same key +order as the corresponding jq program. Nulls are dropped the same way jq's +``with_entries(select(.value != null))`` does, and compact JSON is retained for +stable payload comparisons and GPG signing. Published wrapper files use formatted +JSON so humans can inspect them easily. +""" + +from __future__ import annotations + +import json +from typing import Any + + +def dumps(obj: Any) -> str: + """Compact JSON, byte-equivalent to ``jq -c '.'``. + + jq -c uses ``,``/``:`` separators, emits UTF-8 (no ``\\uXXXX`` escaping of + non-ASCII), and does not escape ``/``. ``json.dumps`` with these options + matches on all three counts. + """ + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) + + +def dumps_formatted(obj: Any) -> str: + """Indented JSON for generated files, with a conventional trailing newline.""" + return json.dumps(obj, indent=2, ensure_ascii=False) + "\n" + + +def drop_none(obj: dict) -> dict: + """Reproduce ``with_entries(select(.value != null))`` for a single object. + + Only top-level ``None`` values are removed, insertion order preserved, + matching the jq idiom used throughout ``generate-manifest.sh``. + """ + return {k: v for k, v in obj.items() if v is not None} + + +def compact(obj: Any) -> str: + """Alias for :func:`dumps` for call-site readability.""" + return dumps(obj) diff --git a/.github/pluginctl/src/pluginctl/core/timeutil.py b/.github/pluginctl/src/pluginctl/core/timeutil.py new file mode 100644 index 00000000..9ea1f1ef --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/timeutil.py @@ -0,0 +1,10 @@ +"""Shared timestamp formatting (one definition of the manifest/build ISO format).""" + +from __future__ import annotations + +import datetime + + +def now_iso() -> str: + """Current UTC time as ``YYYY-MM-DDTHH:MM:SSZ`` (the format every artifact uses).""" + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/.github/pluginctl/src/pluginctl/core/version.py b/.github/pluginctl/src/pluginctl/core/version.py new file mode 100644 index 00000000..25570275 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/core/version.py @@ -0,0 +1,98 @@ +"""Version parsing / comparison helpers ported from the Bash validate + publish scripts. + +These reproduce the exact acceptance rules of the shell functions: + validate_semver -> ^[0-9]+\\.[0-9]+\\.[0-9]+$ + validate_dispatcharr_version -> ^v?[0-9]+\\.[0-9]+\\.[0-9]+$ + version_greater_than -> field-wise major/minor/patch comparison +and the `sort -V -r` ordering used to pick newest release tags. +""" + +from __future__ import annotations + +import re +from functools import cmp_to_key + +_SEMVER_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +_DA_VERSION_RE = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+$") + + +def is_semver(version: str) -> bool: + """True for strict ``X.Y.Z`` (matches validate_semver == 1).""" + return bool(_SEMVER_RE.match(version or "")) + + +def is_dispatcharr_version(version: str) -> bool: + """True for ``X.Y.Z`` or ``vX.Y.Z`` (matches validate_dispatcharr_version == 1).""" + return bool(_DA_VERSION_RE.match(version or "")) + + +def version_greater_than(new_version: str, old_version: str) -> bool: + """Return True when new > old, comparing major/minor/patch as integers. + + Mirrors the Bash ``version_greater_than`` exactly: it reads three + dot-separated fields and compares them numerically; equal versions are not + "greater". + """ + def _parts(v: str) -> tuple[int, int, int]: + fields = (v or "").split(".") + # Bash `IFS='.' read -r A B C` leaves missing fields empty -> treated as 0 + nums = [] + for i in range(3): + try: + nums.append(int(fields[i])) + except (IndexError, ValueError): + nums.append(0) + return nums[0], nums[1], nums[2] + + nmaj, nmin, npat = _parts(new_version) + omaj, omin, opat = _parts(old_version) + if nmaj > omaj: + return True + if nmaj < omaj: + return False + if nmin > omin: + return True + if nmin < omin: + return False + if npat > opat: + return True + return False + + +def _version_sort_key(s: str): + """A key that orders like GNU ``sort -V`` for the version strings we handle. + + Release tags are stripped to bare versions such as ``1.2.3`` or ``1.2.3-1`` + (a migration retry suffix). ``sort -V`` treats each maximal run of digits as a + number and each run of non-digits lexically, so we tokenize accordingly. + """ + tokens: list = [] + for part in re.findall(r"\d+|\D+", s or ""): + if part.isdigit(): + tokens.append((1, int(part), "")) + else: + tokens.append((0, 0, part)) + return tokens + + +def _version_cmp(a: str, b: str) -> int: + ka, kb = _version_sort_key(a), _version_sort_key(b) + if ka < kb: + return -1 + if ka > kb: + return 1 + return 0 + + +def sort_versions_desc(versions: list[str]) -> list[str]: + """Sort bare version strings newest-first, like ``sort -V -r``.""" + return sorted(versions, key=cmp_to_key(_version_cmp), reverse=True) + + +def versioned_tags(all_tags: list[str], plugin_name: str) -> list[str]: + """``-`` release tags for one plugin, newest-first, minus ``-latest``.""" + prefix = f"{plugin_name}-" + latest = f"{plugin_name}-latest" + versions = [t[len(prefix):] for t in all_tags + if t.startswith(prefix) and t != latest] + return [f"{prefix}{v}" for v in sort_versions_desc(versions)] diff --git a/.github/pluginctl/src/pluginctl/feature_flags.py b/.github/pluginctl/src/pluginctl/feature_flags.py new file mode 100644 index 00000000..4218753e --- /dev/null +++ b/.github/pluginctl/src/pluginctl/feature_flags.py @@ -0,0 +1,9 @@ +"""Hardcoded rollout switches for trusted pluginctl automation.""" + +CAPABILITY_CONTRACT_DETECTION = False +SANDBOX_BYPASS_DETECTION = False +SPLIT_MANIFEST_BASE_URLS = False + +# Experimental disclosure support. Keep disabled until its long-term policy is +# decided; this may be removed entirely in a future release. +AI_ASSISTED_MANIFEST_DISCLOSURE = False diff --git a/.github/pluginctl/src/pluginctl/integrations/__init__.py b/.github/pluginctl/src/pluginctl/integrations/__init__.py new file mode 100644 index 00000000..44101277 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/integrations/__init__.py @@ -0,0 +1 @@ +"""Outward integrations: signed webhooks, external README PR, auto-merge.""" diff --git a/.github/pluginctl/src/pluginctl/integrations/automerge.py b/.github/pluginctl/src/pluginctl/integrations/automerge.py new file mode 100644 index 00000000..569763ba --- /dev/null +++ b/.github/pluginctl/src/pluginctl/integrations/automerge.py @@ -0,0 +1,288 @@ +"""Auto-merge pure plugin-update PRs (port of auto-merge-updates.yml inline logic). + +Only squash-merges a PR that carries the ``Plugin Update`` label, none of the +blocking labels, is cleanly mergeable, and independently re-verifies from the diff +that it touches only existing plugins under ``plugins/``. Every rejection is a +notice, never a failure. The workflow already gates on the GitHub App token being +configured (a GITHUB_TOKEN merge would not cascade into publish), so this handler +assumes it is invoked only in that case. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from ..core import actions, gh +from ..core.git import run as _git +from .. import label_catalog + +BLOCKING_LABELS = ("New Plugin", "Repo Update", "Invalid", "QUARANTINE", "Manual Review Required") + + +@dataclass +class LabelDecision: + ok: bool + reason: str = "" + + +@dataclass(frozen=True) +class RateLimits: + author: int + plugin: int + window_hours: int + + +def evaluate_labels(labels: list[str], mergeable: str) -> LabelDecision: + """Require Plugin Update, forbid the blocking labels, require MERGEABLE.""" + label_set = set(labels) + if "Plugin Update" not in label_set: + return LabelDecision(False, "Missing 'Plugin Update' label") + for blocked in BLOCKING_LABELS: + if blocked in label_set: + return LabelDecision(False, f"Has '{blocked}' label") + if mergeable != "MERGEABLE": + return LabelDecision(False, f"PR not cleanly mergeable (mergeable={mergeable})") + return LabelDecision(True) + + +def reverify_is_pure_update(pr_number: str, base_branch: str = "main") -> list[str] | None: + """Re-derive from the diff that the PR only updates existing plugins.""" + if _git("fetch", "origin", f"pull/{pr_number}/merge:pr-merge-{pr_number}", check=False).returncode != 0: + actions.notice(f"Could not fetch merge ref for PR #{pr_number} - skipping.") + return None + _git("checkout", "-q", f"pr-merge-{pr_number}", check=False) + merge_base = _git("merge-base", f"origin/{base_branch}", "HEAD", check=False).stdout.strip() + changed = _git("diff", "--name-only", merge_base, "HEAD", check=False).stdout.splitlines() + + outside = [f for f in changed if not f.startswith("plugins/")] + if outside: + actions.notice(f"Changes outside plugins/ detected - skipping. Files: {' '.join(outside)}") + return None + + slugs = sorted({f.split("/")[1] for f in changed + if f.startswith("plugins/") and len(f.split("/")) > 1}) + if not slugs: + actions.notice("No plugin changes detected in diff - skipping.") + return None + + for slug in slugs: + if _git("show", f"origin/{base_branch}:plugins/{slug}/plugin.json", check=False).returncode != 0: + actions.notice(f"Plugin '{slug}' does not exist on {base_branch} - this is a new " + "plugin, not an update. Skipping.") + return None + return slugs + + +def csv_contains(value: str, csv: str) -> bool: + return any(value.lower() == entry.strip().lower() for entry in csv.split(",") if entry.strip()) + + +def rate_limits_from_env(env: dict[str, str] | None = None) -> tuple[RateLimits | None, str]: + values = os.environ if env is None else env + raw = { + "author": values.get("AUTO_MERGE_AUTHOR_LIMIT", "4"), + "plugin": values.get("AUTO_MERGE_PLUGIN_LIMIT", "4"), + "window_hours": values.get("AUTO_MERGE_RATE_WINDOW_HOURS", "24"), + } + try: + limits = RateLimits(*(int(raw[key] or default) for key, default in + (("author", "4"), ("plugin", "4"), ("window_hours", "24")))) + except ValueError: + return None, "Auto-merge rate-limit settings must be integers" + if limits.window_hours <= 0: + return None, "AUTO_MERGE_RATE_WINDOW_HOURS must be greater than zero" + return limits, "" + + +def rate_limit_decision(author: str, plugins: list[str], history: list[dict], + files_by_pr: dict[str, list[str]], limits: RateLimits) -> LabelDecision: + if limits.author > 0: + author_count = sum(pr.get("author", {}).get("login", "").lower() == author.lower() + for pr in history) + if author_count >= limits.author: + return LabelDecision(False, f"Author '{author}' reached the {limits.author} auto-merge limit") + if limits.plugin > 0: + for plugin in plugins: + prefix = f"plugins/{plugin}/" + count = sum(any(path.startswith(prefix) for path in files_by_pr.get(str(pr.get("number")), [])) + for pr in history) + if count >= limits.plugin: + return LabelDecision(False, f"Plugin '{plugin}' reached the {limits.plugin} auto-merge limit") + return LabelDecision(True) + + +def run(head_sha: str, disabled_reason: str = "") -> int: + repo = os.environ.get("GH_REPO") or os.environ.get("GITHUB_REPOSITORY", "") + + matches_json = gh.pr_list(repo, "open", "number,headRefOid,isDraft") or "[]" + try: + matches = [pr for pr in json.loads(matches_json) if pr.get("headRefOid") == head_sha] + except json.JSONDecodeError: + matches = [] + if len(matches) != 1: + actions.notice(f"Found {len(matches)} open PR(s) matching head SHA {head_sha} - " + "skipping (expected exactly 1).") + return 0 + pr = matches[0] + pr_number = str(pr.get("number")) + if pr.get("isDraft") is True: + actions.notice(f"PR #{pr_number} is a draft - skipping.") + return 0 + + info = gh.pr_view_json(pr_number, "author,labels,mergeable,mergeStateStatus", repo=repo) + data = gh.loads(info) or {} + labels = [l.get("name") for l in (data.get("labels") or [])] + mergeable = data.get("mergeable") or "" + actions.log(f"Labels: {','.join(labels)}") + actions.log(f"Mergeable: {mergeable}") + + decision = evaluate_labels(labels, mergeable) + if not decision.ok: + actions.notice(f"{decision.reason} - skipping.") + _request_manual_review(pr_number, author=(data.get("author") or {}).get("login", ""), + plugins=[], labels=labels, reason=decision.reason, repo=repo) + return 0 + + plugins = reverify_is_pure_update(pr_number) + if not plugins: + _request_manual_review(pr_number, author=(data.get("author") or {}).get("login", ""), + plugins=[], labels=labels, reason="Could not verify this as a pure plugin update.", repo=repo) + return 0 + + author = (data.get("author") or {}).get("login", "") + if disabled_reason: + actions.notice(f"{disabled_reason} - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, + labels=labels, reason=disabled_reason, repo=repo) + return 0 + if csv_contains(author, os.environ.get("AUTO_MERGE_BLACKLIST", "")): + actions.notice(f"PR author '{author}' is on the auto-merge blacklist - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, + reason="The PR author is excluded from automatic merging.", repo=repo) + return 0 + + limits, error = rate_limits_from_env() + if not limits: + actions.notice(f"{error} - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, reason=error, repo=repo) + return 0 + cutoff = (datetime.now(timezone.utc) - timedelta(hours=limits.window_hours)).strftime("%Y-%m-%dT%H:%M:%SZ") + history_json = gh.pr_list(repo, "merged", "number,author", search=f'label:"Auto Merged" merged:>={cutoff}', + limit=100) + history = gh.loads(history_json) + if not isinstance(history, list): + actions.notice("Could not retrieve auto-merge history - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, + reason="Could not retrieve auto-merge history.", repo=repo) + return 0 + files_by_pr = {} + for previous in history: + number = str(previous.get("number")) + paths = gh.pr_files(number, repo) + if paths is None: + actions.notice(f"Could not retrieve changed files for auto-merged PR #{number} - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, + reason="Could not retrieve auto-merge history files.", repo=repo) + return 0 + files_by_pr[number] = paths + decision = rate_limit_decision(author, plugins, history, files_by_pr, limits) + if not decision.ok: + actions.notice(f"{decision.reason} - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, + reason=decision.reason, repo=repo) + return 0 + + if not label_catalog.ensure(repo): + actions.notice("Could not ensure auto-merge labels - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, + reason="Could not ensure automation labels.", repo=repo) + return 0 + if gh.pr_merge_squash(pr_number, repo, delete_branch=False) != 0: + actions.notice(f"Could not merge PR #{pr_number} - skipping.") + _request_manual_review(pr_number, author=author, plugins=plugins, labels=labels, + reason="GitHub could not complete the automatic merge.", repo=repo) + return 0 + if gh.pr_edit_label(pr_number, "Auto Merged", True) != 0: + actions.error(f"PR #{pr_number} merged but could not receive the Auto Merged label.") + return 1 + actions.log(f"Auto-merged PR #{pr_number}") + from . import webhooks + payload = webhooks.pr_auto_merged(pr_number, plugins) + payload.update({"author": author, "pr_url": f"https://github.com/{repo}/pull/{pr_number}"}) + payload.update(_logo_data(repo, plugins)) + payload.update(_plugin_card_data(plugins, repo)) + webhooks.emit("pr.auto_merged", payload) + webhooks.emit_discord("pr.auto_merged", payload, repository=repo) + return 0 + + +def _request_manual_review(pr_number: str, *, author: str, plugins: list[str], labels: list[str], + reason: str, repo: str) -> None: + """Latch manual review and notify only on the absent -> present transition.""" + if "Manual Review Required" in labels: + return + if gh.pr_edit_label(pr_number, "Manual Review Required", True) != 0: + actions.warning(f"Could not add Manual Review Required to PR #{pr_number}.") + return + from . import webhooks + reason_code = "new_plugin" if "New Plugin" in labels else "auto_merge_skipped" + payload = {"pr": pr_number, "author": author, "plugins": plugins, "labels": labels + ["Manual Review Required"], + "reason": reason, "reason_code": reason_code, "pr_url": f"https://github.com/{repo}/pull/{pr_number}"} + payload.update(_logo_data(repo, plugins)) + payload.update(_plugin_card_data(plugins, repo)) + webhooks.emit("pr.review_required", payload) + webhooks.emit_discord("pr.review_required", payload, repository=repo) + + +def _logo_data(repo: str, plugins: list[str]) -> dict[str, str]: + """Return a pinned source-logo URL for the first available changed plugin.""" + sha = _git("rev-parse", "HEAD", check=False).stdout.strip() + if not sha: + return {} + for plugin in plugins: + for ext in ("png", "svg", "jpg", "webp"): + path = f"plugins/{plugin}/logo.{ext}" + if _git("cat-file", "-e", f"HEAD:{path}", check=False).returncode == 0: + return {"logo_url": f"https://raw.githubusercontent.com/{repo}/{sha}/{path}"} + return {} + + +def _plugin_card_data(plugins: list[str], repository: str) -> dict[str, str]: + """Read the first plugin's display name and upstream release links from checkout.""" + if not plugins: + return {} + try: + with open(f"plugins/{plugins[0]}/plugin.json", encoding="utf-8") as fh: + current = json.load(fh) + except (OSError, ValueError): + return {} + result = {"plugin_name": str(current.get("name") or plugins[0]), + "description": str(current.get("description") or ""), + "author": str(current.get("author") or "unknown")} + upstream = str(current.get("repo_url") or "").rstrip("/") + current_version = str(current.get("version") or "") + if not current_version: + return result + external_upstream = current.get("source_type") == "external" and upstream.startswith("https://github.com/") + if not external_upstream: + upstream = f"https://github.com/{repository}" + current_tag = f"v{current_version}" if external_upstream else f"{plugins[0]}-{current_version}" + links = [f"[v{current_version}]({upstream}/releases/tag/{current_tag})"] if external_upstream else [] + previous_text = _git("show", f"origin/main:plugins/{plugins[0]}/plugin.json", check=False).stdout + try: + previous_version = str(json.loads(previous_text).get("version") or "") + except json.JSONDecodeError: + previous_version = "" + if previous_version and previous_version != current_version: + if external_upstream: + links.append(f"[v{previous_version}...v{current_version}]({upstream}/compare/v{previous_version}...v{current_version})") + else: + previous_commit = _git("rev-parse", "origin/main", check=False).stdout.strip() + current_commit = _git("rev-parse", "HEAD", check=False).stdout.strip() + if previous_commit and current_commit: + links.append(f"[{previous_commit[:7]}...{current_commit[:7]}]({upstream}/compare/{previous_commit}...{current_commit})") + result["source_links"] = "-# " + " · ".join(links) + return result diff --git a/.github/pluginctl/src/pluginctl/integrations/external_readme.py b/.github/pluginctl/src/pluginctl/integrations/external_readme.py new file mode 100644 index 00000000..70ee1a69 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/integrations/external_readme.py @@ -0,0 +1,153 @@ +"""Open/update the plugin-listing PR in an external repo (port of update-external-readme.yml). + +Reads the releases-branch README, strips the releases-only preamble, rewrites its +relative links to absolute releases-branch URLs, then creates or updates a PR in +the configured target repo. The text-transform helpers are pure and unit-tested; +the PR plumbing uses the GitHub Contents/PR APIs. Skips when the only change is the +timestamp line. +""" + +from __future__ import annotations + +import base64 +import os +import re +import subprocess +from typing import Optional + +from ..core import actions, gh, git + +BRANCH = "auto/dispatcharr-plugin-readme" +_SOURCE_COMMIT_RE = re.compile(r"Source commit: ([0-9a-f]+)") +_PLUGIN_LINE_RE = re.compile(r"^- [a-z0-9].*@") + + +def extract_metadata(commit_msg: str) -> tuple[str, str]: + """Return (source_commit, plugin_list_block) from the releases-branch commit.""" + m = _SOURCE_COMMIT_RE.search(commit_msg) + source_commit = m.group(1) if m else "" + plugin_lines = [ln for ln in commit_msg.splitlines() if _PLUGIN_LINE_RE.match(ln)] + return source_commit, "\n".join(plugin_lines) + + +def strip_preamble(readme: str, contrib_url: str) -> str: + """Keep the title + a contribute line, drop the intro/Quick Access, keep from Available Plugins.""" + out: list[str] = [] + found = False + for line in readme.splitlines(): + if line.startswith("# Plugin Releases"): + out.append(line) + out.append("") + out.append(f"Want to get your plugin added to this list? Check out the " + f"[plugin repository]({contrib_url}) to learn how to contribute.") + continue + if line.startswith("## Available Plugins"): + found = True + if found: + out.append(line) + return "\n".join(out) + ("\n" if readme.endswith("\n") else "") + + +def rewrite_links(text: str, base_url: str) -> str: + """Rewrite relative ``](./`` links to absolute releases-branch URLs.""" + return text.replace("](./", f"]({base_url}/") + + +def _strip_timestamp(text: str) -> str: + return "\n".join(ln for ln in text.splitlines() if not ln.startswith("*Last updated:")) + + +def run(releases_ref: str = "origin/releases") -> int: + """Read the releases README from ``releases_ref`` and sync it to the target repo. + + Sourcing from a git ref (rather than a working-tree checkout) lets the caller + keep the base branch checked out, where the composite actions and this package + live, while still reading the releases-branch content and its publish commit. + """ + target_repo = os.environ.get("TARGET_REPO", "") + target_path = os.environ.get("TARGET_PATH", "") + source_repo = os.environ.get("SOURCE_REPO") or os.environ.get("GITHUB_REPOSITORY", "") + reviewer = os.environ.get("PR_REVIEWER", "") + + readme = git.show(f"{releases_ref}:README.md") + if readme is None: + actions.error("README.md not found on the releases branch - has the Publish Plugins workflow run yet?") + return 1 + + commit_msg = git.log_format("%B", releases_ref) or "" + source_commit, plugin_list = extract_metadata(commit_msg) + releases_commit = git.rev_parse(releases_ref, short=True) + + contrib_url = f"https://github.com/{source_repo}/blob/main/CONTRIBUTING.md" + readme = strip_preamble(readme, contrib_url) + readme = rewrite_links(readme, f"https://github.com/{source_repo}/tree/releases") + + default_branch = gh.api(f"repos/{target_repo}", jq=".default_branch") + if not default_branch: + actions.error(f"Could not resolve default branch for {target_repo}.") + return 1 + + if gh.api(f"repos/{target_repo}/branches/{BRANCH}") is None: + base_sha = gh.api(f"repos/{target_repo}/git/refs/heads/{default_branch}", jq=".object.sha") + gh.api(f"repos/{target_repo}/git/refs", method="POST", + fields={"ref": f"refs/heads/{BRANCH}", "sha": base_sha or ""}) + actions.log(f"Created branch {BRANCH}") + + existing = gh.api(f"repos/{target_repo}/contents/{target_path}?ref={BRANCH}") + file_sha = "" + if existing: + data = gh.loads(existing) or {} + file_sha = data.get("sha") or "" + try: + existing_content = base64.b64decode(data.get("content", "")).decode("utf-8") + except Exception: + existing_content = "" + if _strip_timestamp(readme) == _strip_timestamp(existing_content): + actions.log("README content unchanged (only timestamp differs) - skipping.") + actions.notice("External README not updated: only the timestamp line changed.") + return 0 + + put_fields = { + "message": f"chore: update plugin releases listing (source commit {source_commit})", + "content": base64.b64encode(readme.encode("utf-8")).decode("ascii"), + "branch": BRANCH, + } + if file_sha: + put_fields["sha"] = file_sha + gh.api(f"repos/{target_repo}/contents/{target_path}", method="PUT", fields=put_fields) + + summary = _build_summary(source_repo, source_commit, releases_commit, plugin_list) + existing_pr = gh.pr_list(target_repo, "open", "number", jq=".[0].number // empty") + if existing_pr: + gh.pr_comment_repo(existing_pr, target_repo, f"### Plugin README update\n\n{summary}") + actions.log(f"Added update comment to existing PR #{existing_pr}") + else: + body = (f"Automated update of the plugin releases listings generated from " + f"[`{source_repo}`](https://github.com/{source_repo}).\n\n{summary}") + labels = None + reviewers = [r.lstrip("@").replace(" ", "") for r in reviewer.split(",") if r.strip()] + _pr_create_with_reviewers(target_repo, BRANCH, default_branch, + "chore: update plugin releases listings", body, reviewers) + actions.log("Created new PR") + return 0 + + +def _build_summary(source_repo, source_commit, releases_commit, plugin_list) -> str: + lines = [ + f"**Source commit:** [`{source_commit}`](https://github.com/{source_repo}/commit/{source_commit})", + f"**Releases commit:** [`{releases_commit}`](https://github.com/{source_repo}/commit/{releases_commit})", + ] + if plugin_list: + lines.append("") + lines.append("**Plugins updated:**") + lines.extend(plugin_list.splitlines()) + return "\n".join(lines) + + +def _pr_create_with_reviewers(repo, head, base, title, body, reviewers) -> None: + args = ["gh", "pr", "create", "--repo", repo, "--head", head, "--base", base, + "--title", title, "--body", body] + for r in reviewers: + if r: + args += ["--reviewer", r] + subprocess.run(args, capture_output=True, text=True) diff --git a/.github/pluginctl/src/pluginctl/integrations/webhooks.py b/.github/pluginctl/src/pluginctl/integrations/webhooks.py new file mode 100644 index 00000000..befd8cae --- /dev/null +++ b/.github/pluginctl/src/pluginctl/integrations/webhooks.py @@ -0,0 +1,269 @@ +"""Signed webhook emitter (new capability). + +Emits a compact JSON event to ``WEBHOOK_URL`` signed with ``WEBHOOK_SECRET`` via +HMAC-SHA256 over the raw request body. Both settings are optional: with either +unset, :func:`emit` is a silent no-op. A delivery failure never raises, it logs +a ``::warning::``, so the pipeline is never failed by webhook problems. + +Header scheme (documented in docs/webhooks.md for the consuming bot): + X-PluginCtl-Event + X-PluginCtl-Delivery + X-PluginCtl-Signature sha256= +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from pathlib import Path +import urllib.error +import urllib.request +import uuid +from typing import Any, Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from ..core import actions +from ..core.timeutil import now_iso + +USER_AGENT = "pluginctl-webhook/1" + + +def sign(secret: str, body: bytes) -> str: + """Return ``sha256=`` HMAC of ``body`` using ``secret`` (utf-8).""" + digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def build_envelope(event: str, data: dict, repository: str, actor: str) -> dict: + return { + "event": event, + "delivered_at": now_iso(), + "repository": repository, + "actor": actor, + "data": data, + } + + +def emit(event: str, data: dict, *, repository: Optional[str] = None, + actor: Optional[str] = None, url: Optional[str] = None, + secret: Optional[str] = None, _opener=None) -> bool: + """Deliver an event. Returns True if sent, False if skipped or failed. + + Configuration falls back to the environment (``WEBHOOK_URL`` / + ``WEBHOOK_SECRET`` / ``GITHUB_REPOSITORY`` / ``GITHUB_ACTOR``) so callers can + pass nothing in CI. + """ + url = url if url is not None else os.environ.get("WEBHOOK_URL", "") + secret = secret if secret is not None else os.environ.get("WEBHOOK_SECRET", "") + repository = repository if repository is not None else os.environ.get("GITHUB_REPOSITORY", "") + actor = actor if actor is not None else os.environ.get("GITHUB_ACTOR", "") + + if not url or not secret: + return False + + envelope = build_envelope(event, data, repository, actor) + body = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + delivery = str(uuid.uuid4()) + request = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + "X-PluginCtl-Event": event, + "X-PluginCtl-Delivery": delivery, + "X-PluginCtl-Signature": sign(secret, body), + }, + ) + opener = _opener or urllib.request.urlopen + try: + with opener(request, timeout=15) as resp: + status = getattr(resp, "status", None) or resp.getcode() + if status and 200 <= int(status) < 300: + actions.log(f"Webhook '{event}' delivered ({delivery}, HTTP {status}).") + return True + actions.warning(f"Webhook '{event}' returned HTTP {status} - ignoring.") + return False + except (urllib.error.URLError, OSError, ValueError) as exc: # never fail the pipeline + actions.warning(f"Webhook '{event}' delivery failed: {exc}") + return False + + +# ---- typed event constructors (thin, so call sites stay readable) ----------- +def pr_validated(pr, author, result, plugins, checks) -> dict: + return {"pr": pr, "author": author, "result": result, + "plugins": plugins, "checks": checks} + + +def pr_closed_unauthorized(pr, author, reason) -> dict: + return {"pr": pr, "author": author, "reason": reason} + + +def pr_quarantined(pr, author, infected) -> dict: + return {"pr": pr, "author": author, "infected": infected} + + +def pr_auto_merged(pr, plugins) -> dict: + return {"pr": pr, "plugins": plugins} + + +def plugin_published(plugin, version, pr, actor) -> dict: + return {"plugin": plugin, "version": version, "pr": pr, "actor": actor} + + +def plugin_yanked(plugin, version, issue, rollback_pr) -> dict: + return {"plugin": plugin, "version": version, "issue": issue, + "rollback_pr": rollback_pr} + + +# ---- Discord Components V2 -------------------------------------------------- +DISCORD_COMPONENTS_V2 = 1 << 15 +SUPPORTED_DISCORD_EVENTS = {"plugin.published", "pr.auto_merged", "pr.review_required"} +DISCORD_FALLBACK_THUMBNAIL = "https://cdn.discordapp.com/embed/avatars/5.png?size=512" +DISCORD_LIME = 0xB6E85A +DISCORD_YELLOW = 0xFEE75C + + +def discord_routes(config: Optional[str] = None) -> dict[str, dict]: + """Parse the secret JSON routing map, returning only well-formed routes.""" + raw = config if config is not None else os.environ.get("DISCORD_WEBHOOKS", "") + try: + parsed = json.loads(raw) if raw else {} + except json.JSONDecodeError: + actions.warning("DISCORD_WEBHOOKS is not valid JSON; Discord delivery skipped.") + return {} + if not isinstance(parsed, dict): + actions.warning("DISCORD_WEBHOOKS must be a JSON object; Discord delivery skipped.") + return {} + routes = {} + for name, route in parsed.items(): + if (isinstance(route, dict) and isinstance(route.get("url"), str) + and isinstance(route.get("events"), list) + and all(isinstance(event, str) for event in route["events"])): + routes[str(name)] = route + else: + actions.warning(f"Ignoring invalid Discord webhook route '{name}'.") + return routes + + +def discord_url(url: str) -> str: + """Add Discord's Components V2 opt-in query argument.""" + parts = urlsplit(url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["with_components"] = "true" + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + +def discord_payload(event: str, data: dict, repository: str = "") -> dict: + """Build a compact Components V2 card. This function performs no IO.""" + if event not in SUPPORTED_DISCORD_EVENTS: + raise ValueError(f"Unsupported Discord event: {event}") + releases = data.get("releases") if event == "plugin.published" else None + if isinstance(releases, list): + return {"flags": DISCORD_COMPONENTS_V2, + "components": [discord_payload(event, release, repository)["components"][0] + for release in releases]} + pr = str(data.get("pr") or "") + pr_url = data.get("pr_url") or (f"https://github.com/{repository}/pull/{pr}" if repository and pr else "") + if event == "plugin.published": + release_url = data.get("release_url") or data.get("manifest_url") or pr_url + name = f"**{data.get('plugin', 'plugin')}**" + version = f"v{data.get('version', '')}" + title = f"## {name} [{version}]({release_url})" if release_url else f"## {name} {version}" + status = "**Plugin Published**" + metadata = data.get("metadata") or "Refresh the in-app plugin browser to download or update." + elif event == "pr.auto_merged": + name = data.get("plugin_name") or (data.get("plugins") or ["Plugin"])[0] + heading = f"**{name}**" + title = f"## {heading} [#{pr}]({pr_url})" if pr_url else f"## {heading} #{pr}" + status = "**Auto Merged**" + metadata = data.get("source_links") or "" + else: + name = data.get("plugin_name") or (data.get("plugins") or ["Plugin"])[0] + heading = f"**{name}**" + title = f"## {heading} [#{pr}]({pr_url})" if pr_url else f"## {heading} #{pr}" + status = "**Manual Review Required**" + metadata = data.get("source_links") or "" + plugins = data.get("plugins") or ([] if not data.get("plugin") else [data["plugin"]]) + description = str(data.get("description") or "") + description = description[:247] + ("..." if len(description) > 247 else "") + text_components: list[dict] = [{"type": 10, "content": title}] + text_components.append({"type": 10, "content": f"-# By {data.get('author', 'unknown')}"}) + if description: + text_components.append({"type": 10, "content": f"*{description}*"}) + section: dict = {"type": 9, "components": text_components} + logo_url = data.get("logo_url") + if not isinstance(logo_url, str) or not logo_url.startswith("https://"): + logo_url = DISCORD_FALLBACK_THUMBNAIL + section["accessory"] = {"type": 11, "media": {"url": logo_url}} + components: list[dict] = [ + {"type": 10, "content": status}, + {"type": 14, "divider": True, "spacing": 1}, + section, + ] + if metadata: + components.append({"type": 10, "content": metadata}) + attachment = data.get("attachment_filename") + if attachment: + components.append({"type": 13, "file": {"url": f"attachment://{attachment}"}}) + container = {"type": 17, "components": components} + if event == "pr.auto_merged": + container["accent_color"] = DISCORD_LIME + elif event == "pr.review_required": + container["accent_color"] = DISCORD_YELLOW + return {"flags": DISCORD_COMPONENTS_V2, "components": [container]} + + +def emit_discord(event: str, data: dict, *, repository: Optional[str] = None, + url: Optional[str] = None, config: Optional[str] = None, _opener=None) -> bool: + """Best-effort direct Discord delivery to all routes subscribed to ``event``.""" + repository = repository if repository is not None else os.environ.get("GITHUB_REPOSITORY", "") + urls = [url] if url else [route["url"] for route in discord_routes(config).values() + if event in route["events"] or "*" in route["events"]] + if not urls: + return False + payload = discord_payload(event, data, repository) + attachment_paths = [data.get("attachment_path")] if data.get("attachment_path") else [] + attachment_paths += [release.get("attachment_path") for release in data.get("releases", []) + if release.get("attachment_path")] + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} + if attachment_paths: + try: + boundary = f"pluginctl-{uuid.uuid4().hex}" + body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"payload_json\"\r\n" + f"Content-Type: application/json\r\n\r\n").encode() + body + for index, attachment_path in enumerate(attachment_paths): + filename = Path(attachment_path).name + content = Path(attachment_path).read_bytes() + body += (f"\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"files[{index}]\"; " + f"filename=\"{filename}\"\r\nContent-Type: application/zip\r\n\r\n").encode() + content + body += f"\r\n--{boundary}--\r\n".encode() + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + except OSError as exc: + actions.warning(f"Discord attachment for '{event}' could not be read: {exc}") + return False + opener = _opener or urllib.request.urlopen + delivered = False + for destination in urls: + try: + request = urllib.request.Request(discord_url(destination), data=body, method="POST", + headers=headers) + with opener(request, timeout=15) as response: + status = getattr(response, "status", None) or response.getcode() + delivered |= bool(status and 200 <= int(status) < 300) + if not (status and 200 <= int(status) < 300): + actions.warning(f"Discord webhook '{event}' returned HTTP {status} - ignoring.") + except urllib.error.HTTPError as exc: + try: + detail = exc.read().decode("utf-8", errors="replace")[:1000] + except OSError: + detail = "" + suffix = f" ({detail})" if detail else "" + actions.warning(f"Discord webhook '{event}' delivery failed: HTTP {exc.code}{suffix}") + except (urllib.error.URLError, OSError, ValueError) as exc: + actions.warning(f"Discord webhook '{event}' delivery failed: {exc}") + return delivered diff --git a/.github/pluginctl/src/pluginctl/label_catalog.py b/.github/pluginctl/src/pluginctl/label_catalog.py new file mode 100644 index 00000000..20105235 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/label_catalog.py @@ -0,0 +1,45 @@ +"""Repository labels required by pluginctl and its workflows.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from .core import actions, gh + + +@dataclass(frozen=True) +class Label: + name: str + color: str + description: str + + +# Baseline labels mirror Dispatcharr/Plugins. The remaining entries are labels +# created by this repository's automation. +LABELS = ( + Label("CodeQL Suppression Used", "FBCA04", "PR relies on an inline CodeQL suppression comment - requires maintainer review"), + Label("Invalid", "333333", "The PR is invalid or created in error."), + Label("New Plugin", "19D64D", "A new plugin being contributed to the repository."), + Label("Plugin Update", "130518", "A plugin already in the repository is being updated."), + Label("Repo Update", "86ADC3", "Scripts or other repository infrastructure."), + Label("Testing", "F1E909", "This PR was created for testing and should not be merged."), + Label("Manual Review Required", "CFD3D7", "Security findings, suppressions, or new plugin require maintainer review"), + Label("QUARANTINE", "FFFF00", "ClamAV detected a potential threat in this PR"), + Label("Rollback", "E11D48", "Version rollback opened by the yank workflow"), + Label("Auto Merged", "0E8A16", "PR was merged automatically after validation"), +) + + +def ensure(repo: str = "") -> bool: + """Create or update every managed label without touching unrelated labels.""" + repository = repo or os.environ.get("GH_REPO") or os.environ.get("GITHUB_REPOSITORY", "") + if not repository: + actions.error("No repository provided while ensuring labels.") + return False + failed = [label.name for label in LABELS + if gh.label_create(label.name, label.color, label.description, repository) != 0] + if failed: + actions.error(f"Could not ensure labels: {', '.join(failed)}") + return False + return True diff --git a/.github/pluginctl/src/pluginctl/publish/__init__.py b/.github/pluginctl/src/pluginctl/publish/__init__.py new file mode 100644 index 00000000..7134aec5 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/__init__.py @@ -0,0 +1 @@ +"""Publish pipeline: build ZIPs + GitHub Releases, manifests, and READMEs.""" diff --git a/.github/pluginctl/src/pluginctl/publish/ai_assisted.py b/.github/pluginctl/src/pluginctl/publish/ai_assisted.py new file mode 100644 index 00000000..66f9348e --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/ai_assisted.py @@ -0,0 +1,72 @@ +"""Best-effort detection of AI attribution in plugin Git history.""" + +from __future__ import annotations + +import re + +from ..core import actions, git + + +TRAILER_RE = re.compile( + r"^(?:co-authored-by|ai-assisted-by|assisted-by|generated-by):\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) + +# Match only agent names in authorship fields, never prose in a commit message. +AGENT_NAME_RE = re.compile( + r"^(?:" + r"claude (?:code|opus|sonnet|haiku|fable)(?: [0-9.]+)?|" + r"(?:openai )?codex|chatgpt|" + r"(?:github )?copilot|gemini(?: cli)?|cursor|aider|cline|roo code|continue|" + r"windsurf|codeium|devin|amp|(?:amazon )?q|tabnine|jetbrains ai|kiro|" + r"augment code|qodo|codiumai|sweep ai|replit agent|hermes agent" + r")(?: \([^)]*\))?$", + re.IGNORECASE, +) + +# Exact known identities are stronger evidence than a display name and cover +# formats already used in this repository. +AGENT_EMAILS = { + "noreply@anthropic.com", + "hermes-agent@g1tech.us", +} + + +def has_attribution(source_branch: str, plugin_name: str) -> bool: + """Return whether source-branch history attributes a plugin change to an AI agent.""" + proc = git.run( + "log", f"origin/{source_branch}", "--format=%aN%x1f%aE%x1f%B%x1e", + "--", f"plugins/{plugin_name}", check=False, + ) + if proc.returncode != 0: + actions.warning(f"Could not inspect AI attribution history for {plugin_name}; " + "using manual disclosure only.") + return False + + for record in proc.stdout.split("\x1e"): + if not record.strip(): + continue + try: + author_name, author_email, message = record.split("\x1f", 2) + except ValueError: + actions.warning(f"Could not parse AI attribution history for {plugin_name}; " + "using manual disclosure only.") + return False + if _is_agent_identity(author_name, author_email): + return True + for trailer in TRAILER_RE.findall(message): + name, email = _identity_parts(trailer) + if _is_agent_identity(name, email): + return True + return False + + +def _identity_parts(value: str) -> tuple[str, str]: + match = re.fullmatch(r"\s*(.*?)\s*<([^<>]+)>\s*", value) + if match: + return match.group(1), match.group(2) + return value.strip(), "" + + +def _is_agent_identity(name: str, email: str) -> bool: + return email.strip().lower() in AGENT_EMAILS or bool(AGENT_NAME_RE.fullmatch(name.strip())) diff --git a/.github/pluginctl/src/pluginctl/publish/cleanup.py b/.github/pluginctl/src/pluginctl/publish/cleanup.py new file mode 100644 index 00000000..c8587ce0 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/cleanup.py @@ -0,0 +1,39 @@ +"""Remove releases for deleted plugins and prune old versions (port of cleanup.sh). + +Runs from the releases-branch checkout directory. +""" + +from __future__ import annotations + +import glob +import os +import shutil + +from ..core import actions, gh +from ..core.version import versioned_tags + + +def run(repository: str, max_versioned_zips: int = 10) -> int: + all_tags = gh.release_list_tags(repository) + + # Remove releases for deleted plugins (guarded on zips/ existing, as in Bash). + if os.path.isdir("zips"): + for release_dir in sorted(glob.glob("metadata/*/")): + plugin_name = os.path.basename(release_dir.rstrip("/")) + if not os.path.isdir(f"plugins/{plugin_name}"): + actions.log(f" Removing deleted plugin releases: {plugin_name}") + for tag in [t for t in all_tags if t.startswith(f"{plugin_name}-")]: + actions.log(f" Deleting release {tag}") + gh.release_delete(tag, repository) + shutil.rmtree(release_dir, ignore_errors=True) + + # Prune old versioned releases per plugin (keep the newest max_versioned_zips). + for plugin_dir in sorted(glob.glob("plugins/*/")): + plugin_name = os.path.basename(plugin_dir.rstrip("/")) + tags = versioned_tags(all_tags, plugin_name) + if len(tags) <= max_versioned_zips: + continue + for old_tag in tags[max_versioned_zips:]: + actions.log(f" Removed release {old_tag} (over limit of {max_versioned_zips})") + gh.release_delete(old_tag, repository) + return 0 diff --git a/.github/pluginctl/src/pluginctl/publish/manifest.py b/.github/pluginctl/src/pluginctl/publish/manifest.py new file mode 100644 index 00000000..a8bac4cd --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/manifest.py @@ -0,0 +1,440 @@ +"""Manifest construction, byte-for-byte equivalent to generate-manifest.sh. + +The pure builders here assemble ``dict`` objects in the *exact* key order of the +jq programs in the Bash script and drop ``None`` the way +``with_entries(select(.value != null))`` does, so ``jsonio.dumps`` reproduces the +compact ``jq -c`` output. GPG signing and the "only re-sign when the payload +changed" logic are preserved in :func:`write_manifest_if_changed` / +:func:`sign_manifest`. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import tempfile +from typing import Optional + +from .. import feature_flags +from ..core import jsonio +from ..core.jsonio import drop_none +from ..core.timeutil import now_iso +from ..core.version import versioned_tags +from . import ai_assisted + + +def _metadata_fields(metadata: dict, min_da, max_da) -> dict: + """The build-metadata fields shared by ``versions[]`` and ``latest`` entries. + + Key order matters: it is the jq emission order the Bash script produced. + """ + return { + "version": metadata.get("version"), + "commit_sha": metadata.get("commit_sha"), + "commit_sha_short": metadata.get("commit_sha_short"), + "build_timestamp": metadata.get("build_timestamp"), + "last_updated": metadata.get("last_updated"), + "checksum_md5": metadata.get("checksum_md5"), + "checksum_sha256": metadata.get("checksum_sha256"), + "min_dispatcharr_version": min_da, + "max_dispatcharr_version": max_da, + "source_url": metadata.get("source_url"), + } + + +def build_version_entry(metadata: dict, url: str, size, canonical_version: str) -> dict: + """A ``versions[]`` entry. + + With metadata present, all fields are populated (nulls dropped). With empty + metadata the Bash else-branch emits only ``{version,url,size}`` using the + tag-derived canonical version. + """ + if metadata: + return drop_none({ + **_metadata_fields(metadata, + metadata.get("min_dispatcharr_version"), + metadata.get("max_dispatcharr_version")), + "url": url, + "size": size, + }) + return {"version": canonical_version, "url": url, "size": size} + + +def override_current_min_max(versioned_zips: list[dict], current_version: str, + min_da: str, max_da: str) -> list[dict]: + """Overlay plugin.json min/max onto the current version's entry (metadata-only updates).""" + out = [] + for entry in versioned_zips: + if entry.get("version") == current_version: + merged = dict(entry) + merged["min_dispatcharr_version"] = min_da if min_da != "" else None + merged["max_dispatcharr_version"] = max_da if max_da != "" else None + out.append(drop_none(merged)) + else: + out.append(entry) + return out + + +def build_plugin_entry(plugin_raw: dict, plugin_name: str, latest_url: str, + registry_url: str, registry_name: str, + versioned_zips: list[dict], latest_metadata: dict, + latest_size_kb, ai_assisted_disclosed: bool = False) -> dict: + """Per-plugin manifest ``.manifest`` payload (metadata//manifest.json).""" + def g(key): + return plugin_raw.get(key) + + latest = None + if latest_metadata: + latest = drop_none({ + **_metadata_fields(latest_metadata, + g("min_dispatcharr_version"), + g("max_dispatcharr_version")), + "latest_url": latest_url, + "url": versioned_zips[0]["url"] if versioned_zips else None, + "size": latest_size_kb, + }) + + return drop_none({ + "slug": plugin_name, + "name": g("name"), + "description": g("description"), + "author": g("author"), + "maintainers": g("maintainers"), + "license": g("license"), + "ai_assisted": True if ai_assisted_disclosed else None, + "deprecated": True if g("deprecated") is True else None, + "source_type": "external" if g("source_type") == "external" else None, + "source_url": g("source_url"), + "repo_url": g("repo_url"), + "discord_thread": g("discord_thread"), + "registry_url": registry_url, + "registry_name": registry_name, + "last_updated": latest_metadata.get("last_updated") if latest_metadata else None, + "latest": latest, + "versions": versioned_zips, + }) + + +def trim_description(desc: str) -> str: + """``desc[:197] + "..."`` when longer than 200 chars, else unchanged.""" + return (desc[:197] + "...") if len(desc) > 200 else desc + + +def build_root_entry(plugin_raw: dict, plugin_name: str, latest_metadata: dict, + latest_size_kb, min_da: str, max_da: str, + latest_url: str, manifest_url: str, + ai_assisted_disclosed: bool = False) -> dict: + """Compact entry for the root manifest's ``plugins[]``.""" + license_id = plugin_raw.get("license") or "" + return drop_none({ + "slug": plugin_name, + "name": plugin_raw.get("name") or "", + "description": trim_description(plugin_raw.get("description") or ""), + "manifest_url": manifest_url, + "author": plugin_raw.get("author") or "", + "license": license_id if license_id != "" else None, + "ai_assisted": True if ai_assisted_disclosed else None, + "deprecated": True if plugin_raw.get("deprecated") is True else None, + "last_updated": latest_metadata.get("last_updated") if latest_metadata else None, + "latest_version": latest_metadata.get("version") if latest_metadata else None, + "latest_md5": latest_metadata.get("checksum_md5") if latest_metadata else None, + "latest_sha256": latest_metadata.get("checksum_sha256") if latest_metadata else None, + "latest_url": latest_url, + "latest_size": latest_size_kb if _num(latest_size_kb) > 0 else None, + "min_dispatcharr_version": min_da if min_da != "" else None, + "max_dispatcharr_version": max_da if max_da != "" else None, + }) + + +def build_root_manifest(registry_url: str, registry_name: str, download_base_url: str, + root_entries: list[dict], metadata_base_url: Optional[str] = None) -> dict: + root = { + "registry_url": registry_url, + "registry_name": registry_name, + } + if metadata_base_url is None: + root["root_url"] = download_base_url + else: + root["download_base_url"] = download_base_url + root["metadata_base_url"] = metadata_base_url + root["plugins"] = root_entries + return root + + +def _num(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +# ---- on-disk wrapper + GPG signing ------------------------------------------ +def write_manifest_if_changed(dest: str, payload: dict, generated_at: str) -> bool: + """Write ``{generated_at, manifest}`` only if the compact payload changed. + + Returns True if written, False if skipped (unchanged), mirroring the Bash + return codes that gate re-signing. + """ + new_compact = jsonio.dumps(payload) + if os.path.isfile(dest): + try: + with open(dest, encoding="utf-8") as fh: + existing = json.load(fh) + if jsonio.dumps(existing.get("manifest")) == new_compact: + return False + except (OSError, ValueError): + pass + wrapper = {"generated_at": generated_at, "manifest": json.loads(new_compact)} + with open(dest, "w", encoding="utf-8") as fh: + fh.write(jsonio.dumps_formatted(wrapper)) + return True + + +def sig_is_current(file: str, gpg_key_id: str) -> bool: + """True when the embedded ``.signature`` was issued by ``gpg_key_id``.""" + try: + with open(file, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return False + sig = data.get("signature") + if not sig: + return False + with tempfile.NamedTemporaryFile("w", suffix=".asc", delete=False) as tmp: + tmp.write(sig + "\n") + tmp_path = tmp.name + try: + out = subprocess.run(["gpg", "--list-packets", tmp_path], + capture_output=True, text=True).stdout + finally: + os.unlink(tmp_path) + m = re.search(r"issuer key ID ([A-Fa-f0-9]{16})", out) + if not m: + return False + return m.group(1).upper() == (gpg_key_id or "").upper() + + +def sign_manifest(file: str, gpg_key_id: str, passphrase: Optional[str]) -> bool: + """Sign the ``.manifest`` payload and embed the armored signature. False on failure.""" + if not gpg_key_id: + return True + with open(file, encoding="utf-8") as fh: + data = json.load(fh) + payload = jsonio.dumps(data["manifest"]) + args = ["gpg", "--batch", "--yes", "--armor", "--detach-sign", + "--local-user", gpg_key_id, "--output", "-"] + if passphrase: + args += ["--passphrase", passphrase, "--pinentry-mode", "loopback"] + proc = subprocess.run(args, input=payload, capture_output=True, text=True) + sig = proc.stdout.rstrip("\n") # command-substitution parity: no trailing newline + if proc.returncode != 0 or not sig: + return False + data["signature"] = sig + with open(file, "w", encoding="utf-8") as fh: + fh.write(jsonio.dumps_formatted(data)) + return True + + +# ---- GPG key setup + full generation orchestrator --------------------------- +def import_gpg_key(private_key: str) -> tuple[str, bool]: + """Import the armored key and return (key_id, signing_failed). + + Empty ``private_key`` -> ("", False) (signing disabled, not a failure). + """ + from ..core import actions + if not private_key: + actions.log("GPG_PRIVATE_KEY not set - signatures will be skipped.") + return "", False + subprocess.run(["gpg", "--batch", "--import"], input=private_key, + capture_output=True, text=True) + out = subprocess.run(["gpg", "--list-secret-keys", "--keyid-format", "LONG"], + capture_output=True, text=True).stdout + key_id = "" + for line in out.splitlines(): + if line.startswith("sec"): + parts = line.split() + if len(parts) >= 2 and "/" in parts[1]: + key_id = parts[1].split("/", 1)[1] + break + if key_id: + actions.log(f"GPG signing enabled (key: {key_id})") + return key_id, False + actions.warning("GPG key import succeeded but no usable secret key found - signatures will be skipped.") + return "", True + + +def _canonical(version: str) -> str: + return re.sub(r"-[0-9]+$", "", version) + + +ICON_EXTS = ("png", "svg", "jpg", "webp") + + +def _sync_icon(plugin_dir: str, plugin_name: str) -> None: + """Copy the first supported source logo beside the plugin metadata.""" + import shutil + + for ext in ICON_EXTS: + source = os.path.join(plugin_dir, f"logo.{ext}") + if os.path.isfile(source): + shutil.copy(source, f"metadata/{plugin_name}/logo.{ext}") + return + + +def strip_signatures() -> None: + """Remove ``.signature`` from every manifest (no key configured or signing failed).""" + import glob as _glob + for f in _glob.glob("metadata/*/manifest.json") + ["manifest.json"]: + if not os.path.isfile(f): + continue + try: + with open(f, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + continue + if "signature" in data: + del data["signature"] + with open(f, "w", encoding="utf-8") as fh: + fh.write(jsonio.dumps_formatted(data)) + + +def generate(source_branch: str, releases_branch: str, repository: str, + build_meta_dir: str) -> int: + """Full generate-manifest.sh: per-plugin manifests, root manifest, signing.""" + import glob + from ..core import actions, gh + + generated_at = now_iso() + registry_url = f"https://github.com/{repository}" + registry_name = repository + download_base_url = f"https://github.com/{repository}/releases/download" + raw_releases_url = f"https://raw.githubusercontent.com/{repository}/{releases_branch}" + metadata_base_url = "" + if feature_flags.SPLIT_MANIFEST_BASE_URLS: + metadata_base_url = gh.pages_url(repository).rstrip("/") or raw_releases_url + + key_id, signing_failed = import_gpg_key(os.environ.get("GPG_PRIVATE_KEY", "")) + passphrase = os.environ.get("GPG_PASSPHRASE") or None + + def maybe_sign(path: str) -> None: + nonlocal signing_failed + if not sign_manifest(path, key_id, passphrase): + actions.warning(f"GPG signing failed for {path} - all signatures will be removed.") + signing_failed = True + + def sign_if_needed(path: str, written: bool) -> None: + if written: + maybe_sign(path) + elif key_id and not signing_failed and not sig_is_current(path, key_id): + maybe_sign(path) + + all_tags = gh.release_list_tags(repository) + root_entries: list[dict] = [] + plugin_count = 0 + + for plugin_dir in sorted(glob.glob("plugins/*/")): + plugin_file = os.path.join(plugin_dir, "plugin.json") + if not os.path.isfile(plugin_file): + continue + plugin_name = os.path.basename(plugin_dir.rstrip("/")) + plugin_key = plugin_name.replace("-", "_") + with open(plugin_file, encoding="utf-8") as fh: + raw = json.load(fh) + current_version = str(raw.get("version")) + min_da = str(raw.get("min_dispatcharr_version") or "") + max_da = str(raw.get("max_dispatcharr_version") or "") + unlisted = raw.get("unlisted") is True + ai_assisted_disclosed = ( + feature_flags.AI_ASSISTED_MANIFEST_DISCLOSURE and + (raw.get("ai_assisted") is True or + ai_assisted.has_attribution(source_branch, plugin_name)) + ) + actions.log(f" {plugin_name}") + + existing_manifest_file = f"metadata/{plugin_name}/manifest.json" + os.makedirs(f"metadata/{plugin_name}", exist_ok=True) + if feature_flags.SPLIT_MANIFEST_BASE_URLS: + _sync_icon(plugin_dir, plugin_name) + existing_manifest = None + if os.path.isfile(existing_manifest_file): + try: + with open(existing_manifest_file, encoding="utf-8") as fh: + existing_manifest = json.load(fh) + except ValueError: + existing_manifest = None + + prefix = f"{plugin_name}-" + tags = versioned_tags(all_tags, plugin_name) + + versioned_zips: list[dict] = [] + latest_metadata: dict = {} + latest_zip_version = "" + latest_size_kb = 0 + latest_size_set = False + + for release_tag in tags: + zip_version = release_tag[len(prefix):] + zip_url = f"{plugin_name}-{zip_version}/{plugin_name}-{zip_version}.zip" + canonical_version = _canonical(zip_version) + + metadata: dict = {} + fresh = os.path.join(build_meta_dir, plugin_key, f"{plugin_key}-{canonical_version}.json") + if build_meta_dir and os.path.isfile(fresh): + with open(fresh, encoding="utf-8") as fh: + metadata = json.load(fh) + elif existing_manifest is not None: + for entry in ((existing_manifest.get("manifest") or {}).get("versions") or []): + if entry.get("version") == canonical_version: + metadata = entry + break + + zip_size_kb = 0 + if metadata: + zip_size_kb = metadata.get("size_kb", metadata.get("size", 0)) or 0 + if not latest_size_set: + latest_size_kb = zip_size_kb + latest_size_set = True + + versioned_zips.append(build_version_entry(metadata, zip_url, zip_size_kb, canonical_version)) + if metadata and not latest_metadata: + latest_metadata = metadata + latest_zip_version = zip_version + + latest_url = "" + if latest_zip_version: + latest_url = f"{plugin_name}-{latest_zip_version}/{plugin_name}-{latest_zip_version}.zip" + + versioned_zips = override_current_min_max(versioned_zips, current_version, min_da, max_da) + + plugin_entry = build_plugin_entry(raw, plugin_name, latest_url, registry_url, + registry_name, versioned_zips, latest_metadata, + latest_size_kb, ai_assisted_disclosed) + written = write_manifest_if_changed(existing_manifest_file, plugin_entry, generated_at) + sign_if_needed(existing_manifest_file, written) + + if unlisted: + continue + + manifest_url = (f"metadata/{plugin_name}/manifest.json" + if feature_flags.SPLIT_MANIFEST_BASE_URLS + else f"{raw_releases_url}/metadata/{plugin_name}/manifest.json") + root_entries.append(build_root_entry(raw, plugin_name, latest_metadata, + latest_size_kb, min_da, max_da, + latest_url, manifest_url, + ai_assisted_disclosed)) + plugin_count += 1 + + inner_root = build_root_manifest( + registry_url, registry_name, download_base_url, root_entries, + metadata_base_url if feature_flags.SPLIT_MANIFEST_BASE_URLS else None) + written = write_manifest_if_changed("manifest.json", inner_root, generated_at) + sign_if_needed("manifest.json", written) + + if signing_failed or not key_id: + actions.warning("Removing all manifest signatures (no GPG key configured or signing failed).") + strip_signatures() + + actions.log(f"Generated manifest.json with {plugin_count} plugin(s).") + return 0 diff --git a/.github/pluginctl/src/pluginctl/publish/readmes.py b/.github/pluginctl/src/pluginctl/publish/readmes.py new file mode 100644 index 00000000..ee0c3243 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/readmes.py @@ -0,0 +1,437 @@ +"""Per-plugin and root READMEs for the releases branch. + +Ports plugin-readmes.sh and releases-readme.sh. Output is kept identical to the +Bash except for one intentional correction, called out in the plan: the +deprecated-plugin "All Versions" count now comes from the per-plugin manifest's +``versions[]`` (same source the active section uses) instead of the removed +``zips//*.zip`` path, which always rendered 0. +""" + +from __future__ import annotations + +import datetime +import glob +import json +import os +import re +from typing import Optional + +from ..core import git +from ..core.timeutil import now_iso + + +def fmt_date(value: str) -> str: + """ISO8601 -> "Mon DD YYYY, HH:MM UTC"; returns the input unchanged on failure.""" + if not value: + return value + try: + text = value.replace("Z", "+00:00") + dt = datetime.datetime.fromisoformat(text) + if dt.tzinfo is not None: + dt = dt.astimezone(datetime.timezone.utc) + return dt.strftime("%b %d %Y, %H:%M UTC") + except ValueError: + return value + + +def shields_encode(s: str) -> str: + """shields.io path escaping: _ -> __, - -> --, space -> _ (order matters).""" + s = s.replace("_", "__") + s = s.replace("-", "--") + s = s.replace(" ", "_") + return s + + +def _load_json(path: str) -> Optional[dict]: + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except (OSError, ValueError): + return None + + +def _g(data: Optional[dict], key: str, default: str = "") -> str: + if not data: + return default + val = data.get(key) + return default if val is None else str(val) + + +# --------------------------------------------------------------------------- # +# Per-plugin README (metadata//README.md) +# --------------------------------------------------------------------------- # +def render_plugin_readme(plugin_name: str, plugin_raw: dict, manifest: dict, + download_base_url: str, repository: str, source_branch: str, + last_updated: str, has_readme: bool, + source_readme: str = "") -> str: + name = _g(plugin_raw, "name") + description = _g(plugin_raw, "description") + author = _g(plugin_raw, "author") + maintainers = ", ".join(str(m) for m in (plugin_raw.get("maintainers") or [])) + repo_url = _g(plugin_raw, "repo_url") + discord_thread = _g(plugin_raw, "discord_thread") + license_id = _g(plugin_raw, "license") + min_da = _g(plugin_raw, "min_dispatcharr_version") + max_da = _g(plugin_raw, "max_dispatcharr_version") + version = _g(plugin_raw, "version") + + manifest_body = manifest.get("manifest", {}) if manifest else {} + latest = manifest_body.get("latest") or {} + latest_url_path = latest.get("latest_url") or "" + latest_full_url = f"{download_base_url}/{latest_url_path}" if download_base_url and latest_url_path else "" + + L: list[str] = [] + L.append("[Back to All Plugins](../../README.md)") + L.append("") + L.append(f"# {name}") + L.append("") + L.append(f"**Version:** `{version}` | **Author:** {author} | **Last Updated:** {fmt_date(last_updated)}") + L.append("") + L.append(description) + L.append("") + + badges = _badges(license_id, discord_thread, repo_url) + if badges: + L.append(badges) + L.append("") + compat = _compat_badges(min_da, max_da) + if compat: + L.append(compat) + L.append("") + + L.append("## Downloads") + L.append("") + L.append("### Latest Release") + L.append("") + if latest_full_url: + L.append(f"- **Download:** [`{plugin_name}-latest.zip`]({latest_full_url})") + build_ts = latest.get("build_timestamp") or "" + commit_sha = latest.get("commit_sha") or "" + commit_short = latest.get("commit_sha_short") or "" + md5 = latest.get("checksum_md5") or "" + sha256 = latest.get("checksum_sha256") or "" + if build_ts: + L.append(f"- **Built:** {fmt_date(build_ts)}") + if commit_sha: + L.append(f"- **Source Commit:** [`{commit_short}`](https://github.com/{repository}/commit/{commit_sha})") + if md5 or sha256: + L.append("") + L.append("**Checksums:**") + L.append("```") + if md5: + L.append(f"MD5: {md5}") + if sha256: + L.append(f"SHA256: {sha256}") + L.append("```") + + L.append("") + L.append("### All Versions") + L.append("") + L.append("| Version | Download | Built | Commit | MD5 | SHA256 |") + L.append("|---------|----------|-------|--------|-----|--------|") + for ver in (manifest_body.get("versions") or []): + v = ver.get("version") or "" + if not v: + continue + url_path = ver.get("url") or "" + full_url = f"{download_base_url}/{url_path}" if download_base_url and url_path else "" + commit_sha = ver.get("commit_sha") or "" + commit_short = ver.get("commit_sha_short") or "" + build_ts = ver.get("build_timestamp") or "" + md5 = ver.get("checksum_md5") or "" + sha256 = ver.get("checksum_sha256") or "" + build_date = fmt_date(build_ts) + commit_cell = f"[`{commit_short}`](https://github.com/{repository}/commit/{commit_sha})" if commit_sha else "-" + download_cell = f"[Download]({full_url})" if full_url else "-" + L.append(f"| `{v}` | {download_cell} | {build_date or '-'} | {commit_cell} | {md5 or '-'} | {sha256 or '-'} |") + + L.append("") + L.append("---") + L.append("") + footer = "" + if maintainers: + footer = f"**Maintainers:** {maintainers} | " + footer += (f"**Source:** [Browse Plugin]" + f"(https://github.com/{repository}/tree/{source_branch}/plugins/{plugin_name})") + L.append(footer) + L.append("") + L.append("**Metadata:** [View full manifest](./manifest.json)") + + if has_readme: + L.append("") + L.append("---") + L.append("") + L.append("## Plugin README") + L.append("") + L.append(source_readme.rstrip("\n")) + + return "\n".join(L) + "\n" + + +def _badges(license_id: str, discord_thread: str, repo_url: str) -> str: + badges: list[str] = [] + if license_id: + badges.append(f"[![License: {license_id}]" + f"(https://img.shields.io/badge/License-{shields_encode(license_id)}-blue?style=flat-square)]" + f"(https://spdx.org/licenses/{license_id}.html)") + if discord_thread: + badges.append("[![Discord](https://img.shields.io/badge/Discord-Discussion-5865F2?style=flat-square&logo=discord&logoColor=white)]" + f"({discord_thread})") + if repo_url: + badges.append("[![Repository](https://img.shields.io/badge/GitHub-Repository-181717?style=flat-square&logo=github&logoColor=white)]" + f"({repo_url})") + return " ".join(badges) + + +def _compat_badges(min_da: str, max_da: str) -> str: + if not (min_da or max_da): + return "" + out = "" + if min_da: + out = f"![Dispatcharr min](https://img.shields.io/badge/Dispatcharr_min-{shields_encode(min_da)}-brightgreen?style=flat-square)" + if max_da: + if out: + out += " " + out += f"![Dispatcharr max](https://img.shields.io/badge/Dispatcharr_max-{shields_encode(max_da)}-orange?style=flat-square)" + return out + + +def version_count_from_manifest(manifest_file: str) -> int: + """Deprecated-plugin fix: derive count from the manifest's versions[] (was ls zips/).""" + data = _load_json(manifest_file) + if not data: + return 0 + versions = (data.get("manifest") or {}).get("versions") + return len(versions) if isinstance(versions, list) else 0 + + +# --------------------------------------------------------------------------- # +# Root releases README (README.md on the releases branch) +# --------------------------------------------------------------------------- # +def anchor_for(name: str) -> str: + """GitHub-style heading anchor: lowercase, non [a-z0-9-] -> -, collapse runs.""" + a = name.lower() + a = re.sub(r"[^a-z0-9-]", "-", a) + a = re.sub(r"-+", "-", a) + return a + + +def table_row(plugin_raw: dict, deprecated_pass: bool) -> str: + name = _g(plugin_raw, "name") + version = _g(plugin_raw, "version") + author = _g(plugin_raw, "author") + description = _g(plugin_raw, "description") + license_cell = _g(plugin_raw, "license", "-") or "-" + suffix = " (deprecated)" if deprecated_pass else "" + return f"| [`{name}`](#{anchor_for(name)}){suffix} | `{version}` | {author} | {license_cell} | {description} |" + + +def render_plugin_block(*, is_deprecated: bool, plugin_name: str, plugin_raw: dict, + manifest: Optional[dict], last_updated: str, commit_sha: str, + commit_sha_short: str, version_count, repository: str, + source_branch: str, releases_branch: str, download_base_url: str, + has_source_readme: bool) -> str: + name = _g(plugin_raw, "name") + version = _g(plugin_raw, "version") + author = _g(plugin_raw, "author") + description = _g(plugin_raw, "description") + maintainers = ", ".join(str(m) for m in (plugin_raw.get("maintainers") or [])) + license_id = _g(plugin_raw, "license") + min_da = _g(plugin_raw, "min_dispatcharr_version") + max_da = _g(plugin_raw, "max_dispatcharr_version") + repo_url = _g(plugin_raw, "repo_url") + discord_thread = _g(plugin_raw, "discord_thread") + + latest_url_path = "" + if manifest: + latest_url_path = ((manifest.get("manifest") or {}).get("latest") or {}).get("latest_url") or "" + zip_url = f"{download_base_url}/{latest_url_path}" if download_base_url and latest_url_path else "" + + source_url = f"https://github.com/{repository}/tree/{source_branch}/plugins/{plugin_name}" + readme_url = f"https://github.com/{repository}/blob/{source_branch}/plugins/{plugin_name}/README.md" + releases_readme_url = f"https://github.com/{repository}/blob/{releases_branch}/metadata/{plugin_name}/README.md" + commit_url = f"https://github.com/{repository}/commit/{commit_sha}" + releases_dir = f"./metadata/{plugin_name}" + + suffix = " (deprecated)" if is_deprecated else "" + + L: list[str] = [] + L.append(f"### [{name}]({releases_readme_url}){suffix}") + L.append("") + L.append(f"**Version:** `{version}` | **Author:** {author} | **Last Updated:** {fmt_date(last_updated)}") + L.append("") + L.append(description) + L.append("") + badges = _badges(license_id, discord_thread, repo_url) + if badges: + L.append(badges) + L.append("") + compat = _compat_badges(min_da, max_da) + if compat: + L.append(compat) + L.append("") + L.append("**Downloads:**") + if zip_url: + L.append(f"- [Latest Release (`{version}`)]({zip_url})") + L.append(f"- [All Versions ({version_count} available)]({releases_dir})") + L.append("") + footer = "" + if maintainers: + footer = f"**Maintainers:** {maintainers} | " + footer += f"**Source:** [Browse]({source_url})" + if has_source_readme: + footer += f" | [README]({readme_url})" + footer += f" | **Last Change:** [`{commit_sha_short}`]({commit_url})" + L.append(footer) + L.append("") + L.append("---") + L.append("") + return "\n".join(L) + "\n" + + +# --------------------------------------------------------------------------- # +# Orchestrators (IO): called from the releases-branch checkout directory +# --------------------------------------------------------------------------- # +def _plugin_commit(source_branch: str, plugin_dir: str) -> tuple[str, str, str]: + """(last_updated, commit_sha, commit_sha_short) of the plugin's last commit. + + One ``git log`` call for all three fields; empty strings when there is none. + """ + out = git.log_format("%cI%n%H%n%h", f"origin/{source_branch}", plugin_dir) + if not out: + return "", "", "" + fields = out.splitlines() + fields += [""] * (3 - len(fields)) + return fields[0], fields[1], fields[2] + + +def _plugin_last_updated(source_branch: str, plugin_dir: str) -> str: + return _plugin_commit(source_branch, plugin_dir)[0] or now_iso() + + +def generate_plugin_readmes(source_branch: str, repository: str) -> None: + """Write metadata//README.md for every plugin (plugin-readmes.sh).""" + root = _load_json("manifest.json") or {} + root_manifest = root.get("manifest") or {} + download_base_url = root_manifest.get("download_base_url") or root_manifest.get("root_url") or "" + for plugin_dir in sorted(glob.glob("plugins/*/")): + plugin_name = os.path.basename(plugin_dir.rstrip("/")) + plugin_file = os.path.join(plugin_dir, "plugin.json") + plugin_raw = _load_json(plugin_file) + if plugin_raw is None: + continue + manifest_file = f"metadata/{plugin_name}/manifest.json" + manifest = _load_json(manifest_file) + if manifest is None: + continue + last_updated = _plugin_last_updated(source_branch, plugin_dir) + source_readme_path = os.path.join(plugin_dir, "README.md") + has_readme = os.path.isfile(source_readme_path) + source_readme = _read(source_readme_path) if has_readme else "" + content = render_plugin_readme( + plugin_name, plugin_raw, manifest, download_base_url, repository, source_branch, + last_updated, has_readme, source_readme) + with open(f"metadata/{plugin_name}/README.md", "w", encoding="utf-8") as fh: + fh.write(content) + + +def generate_releases_readme(source_branch: str, releases_branch: str, repository: str) -> None: + """Write the root README.md (releases-readme.sh).""" + root = _load_json("manifest.json") or {} + root_manifest = root.get("manifest") or {} + download_base_url = root_manifest.get("download_base_url") or root_manifest.get("root_url") or "" + + raws: dict[str, dict] = {} + has_deprecated = False + for pd in sorted(glob.glob("plugins/*/")): + pname = os.path.basename(pd.rstrip("/")) + raw = _load_json(os.path.join(pd, "plugin.json")) + if raw is None: + continue + raws[pname] = raw + if raw.get("deprecated") is True: + has_deprecated = True + + L: list[str] = [] + L.append("# Plugin Releases") + L.append("") + L.append("This branch contains all published plugin releases.") + L.append("") + L.append("## Quick Access") + L.append("") + L.append("- [manifest.json](./manifest.json) - Complete plugin registry with metadata") + L.append("- [metadata/](./metadata/) - Per-plugin manifests and READMEs") + L.append("") + L.append("## Available Plugins") + L.append("") + L.append("| Plugin | Version | Author | License | Description |") + L.append("|--------|---------|-------|---------|-------------|") + + for deprecated_pass in (False, True): + for raw in raws.values(): + if raw.get("unlisted") is True: + continue + if (raw.get("deprecated") is True) is not deprecated_pass: + continue + L.append(table_row(raw, deprecated_pass)) + + L.append("") + L.append("---") + L.append("") + + def emit_block(pname: str, raw: dict, is_deprecated: bool) -> None: + pd = f"plugins/{pname}/" + manifest = _load_json(f"metadata/{pname}/manifest.json") + last_updated, commit_sha, commit_short = _plugin_commit(source_branch, pd) + last_updated = last_updated or now_iso() + commit_sha = commit_sha or "unknown" + commit_short = commit_short or "unknown" + version_count = version_count_from_manifest(f"metadata/{pname}/manifest.json") + has_source_readme = os.path.isfile(f"plugins/{pname}/README.md") + L.append(render_plugin_block( + is_deprecated=is_deprecated, plugin_name=pname, plugin_raw=raw, + manifest=manifest, last_updated=last_updated, commit_sha=commit_sha, + commit_sha_short=commit_short, version_count=version_count, + repository=repository, source_branch=source_branch, + releases_branch=releases_branch, download_base_url=download_base_url, + has_source_readme=has_source_readme)) + + # Active detailed sections + for pname, raw in raws.items(): + if raw.get("deprecated") is True or raw.get("unlisted") is True: + continue + emit_block(pname, raw, False) + + if has_deprecated: + L.append("") + L.append("## Deprecated Plugins") + L.append("") + L.append("These plugins are deprecated and may be removed in the future.") + L.append("") + for pname, raw in raws.items(): + if raw.get("deprecated") is not True or raw.get("unlisted") is True: + continue + emit_block(pname, raw, True) + + L.append("## Using the Manifest") + L.append("") + L.append("Fetch `manifest.json` to programmatically access plugin metadata and download URLs:") + L.append("") + L.append("```bash") + L.append(f"curl https://raw.githubusercontent.com/{repository}/{releases_branch}/manifest.json") + L.append("```") + L.append("") + L.append("---") + L.append("") + L.append(f"*Last updated: {datetime.datetime.now(datetime.timezone.utc).strftime('%b %d %Y, %H:%M UTC')}*") + + with open("README.md", "w", encoding="utf-8") as fh: + fh.write("\n".join(L) + "\n") + + +def _read(path: str) -> str: + try: + with open(path, encoding="utf-8") as fh: + return fh.read() + except OSError: + return "" diff --git a/.github/pluginctl/src/pluginctl/publish/run.py b/.github/pluginctl/src/pluginctl/publish/run.py new file mode 100644 index 00000000..5efd9eda --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/run.py @@ -0,0 +1,270 @@ +"""Publish orchestrator (port of run.sh). + +Clones the repo, sets up the releases branch, runs the build/cleanup/manifest/readme +phases, then commits and pushes, skipping commits whose only diff is timestamp +noise. Validated end to end in a fork per the rollout plan; the phase logic it +calls is unit-tested individually. +""" + +from __future__ import annotations + +import os +import json +import subprocess +import tempfile + +from ..core import actions +from ..core.git import configure_identity, run as _git +from . import cleanup, manifest, readmes, zips + +RELEASES_BRANCH = "releases" +MAX_VERSIONED_ZIPS = 10 +RELEASES_BRANCH_VERSION = "3" + + +def run(source_branch: str) -> int: + repository = os.environ["GITHUB_REPOSITORY"] + token = os.environ["GITHUB_TOKEN"] + app_slug = os.environ.get("APP_SLUG", "") + force_rebuild = os.environ.get("FORCE_REBUILD", "false") == "true" + force_plugin = os.environ.get("FORCE_REBUILD_PLUGIN", "") + remote = f"https://x-access-token:{token}@github.com/{repository}.git" + + actions.log(f"Publishing plugins from {source_branch} to {RELEASES_BRANCH}") + + workdir = tempfile.mkdtemp() + build_meta_dir = tempfile.mkdtemp() + start_cwd = os.getcwd() + try: + _git("clone", "--no-checkout", remote, f"{workdir}/repo") + os.chdir(f"{workdir}/repo") + configure_identity(app_slug) + _setup_releases_branch(repository, source_branch, token, remote, + force_rebuild, force_plugin) + + # Clean regenerated top-level artifacts and fetch source plugins. + for f in ("manifest.json", "README.md"): + if os.path.exists(f): + os.remove(f) + _git("fetch", "origin", source_branch) + _git("checkout", f"origin/{source_branch}", "--", "plugins") + os.makedirs("zips", exist_ok=True) + + if not force_rebuild: + current = _read("REPO_VER") + if current != RELEASES_BRANCH_VERSION: + actions.error("Releases branch version mismatch.") + actions.error(f" Expected : {RELEASES_BRANCH_VERSION}") + actions.error(f" Found : {current or '(none - migration not run)'}") + actions.error("Run the 'Migrate Releases to GitHub Releases' workflow first, then re-run.") + return 1 + + actions.log("\n=== Building ZIPs ===") + rc = zips.run(source_branch, repository, build_meta_dir) + if rc != 0: + return rc + actions.log("\n=== Cleaning up old releases ===") + cleanup.run(repository, MAX_VERSIONED_ZIPS) + actions.log("\n=== Generating manifests ===") + manifest.generate(source_branch, RELEASES_BRANCH, repository, build_meta_dir) + actions.log("\n=== Generating per-plugin READMEs ===") + readmes.generate_plugin_readmes(source_branch, repository) + actions.log("\n=== Generating releases README ===") + readmes.generate_releases_readme(source_branch, RELEASES_BRANCH, repository) + + return _commit_and_push(repository, source_branch, remote) + finally: + os.chdir(start_cwd) + subprocess.run(["rm", "-rf", workdir, build_meta_dir], check=False) + + +def _setup_releases_branch(repository, source_branch, token, remote, + force_rebuild, force_plugin) -> None: + from ..core import gh + branch_exists = _git("ls-remote", "--exit-code", "--heads", "origin", + RELEASES_BRANCH, check=False).returncode == 0 + + if force_rebuild and force_plugin: + if branch_exists: + _checkout_existing() + else: + _orphan_init() + actions.log(f"Targeted force rebuild: deleting GitHub Releases for {force_plugin}") + for tag in gh.release_list_tags(repository): + if tag.startswith(f"{force_plugin}-"): + gh.release_delete(tag, repository) + mf = f"metadata/{force_plugin}/manifest.json" + if os.path.exists(mf): + os.remove(mf) + elif force_rebuild: + actions.log(f"Force rebuild requested - deleting all plugin GitHub Releases and resetting {RELEASES_BRANCH}.") + _git("fetch", "origin", source_branch, check=False) + _git("checkout", f"origin/{source_branch}", "--", "plugins", check=False) + if os.path.isdir("plugins"): + import glob + for plugin_dir in glob.glob("plugins/*/"): + pname = os.path.basename(plugin_dir.rstrip("/")) + for tag in gh.release_list_tags(repository): + if tag.startswith(f"{pname}-"): + gh.release_delete(tag, repository) + subprocess.run(["rm", "-rf", "plugins"], check=False) + _orphan_init(f"Initialize {RELEASES_BRANCH} branch (force rebuild)") + _git("push", "--force", remote, RELEASES_BRANCH) + elif branch_exists: + _checkout_existing() + else: + _orphan_init() + + +def _orphan_init(message: str = f"Initialize {RELEASES_BRANCH} branch") -> None: + _git("checkout", "--orphan", RELEASES_BRANCH) + _git("rm", "-rf", ".", check=False) + _git("commit", "--allow-empty", "-m", message) + + +def _checkout_existing() -> None: + _git("checkout", RELEASES_BRANCH) + _git("pull", "origin", RELEASES_BRANCH, check=False) + + +def _commit_and_push(repository, source_branch, remote) -> int: + actions.log("\n=== Committing ===") + subprocess.run(["rm", "-rf", "plugins"], check=False) + _git("rm", "-rf", "--cached", "plugins", check=False) + with open("REPO_VER", "w", encoding="utf-8") as fh: + fh.write(RELEASES_BRANCH_VERSION + "\n") + _git("add", "metadata", "manifest.json", "README.md", "REPO_VER") + + if _git("diff", "--cached", "--quiet", check=False).returncode == 0: + actions.log("No changes to commit.") + return 0 + + if _only_timestamps(): + actions.log("No meaningful changes (only timestamps updated) - skipping commit.") + return 0 + + source_commit = _git("rev-parse", "--short", f"origin/{source_branch}").stdout.strip() + plugin_list = "" + changed = _read("changed_plugins.txt") + if changed.strip(): + plugin_list = "\n\n" + "\n".join(f"- {line}" for line in changed.splitlines() if line) + _git("commit", "-m", f"Publish plugin updates from {source_branch}\n\n" + f"Source commit: {source_commit}{plugin_list}") + _git("push", remote, RELEASES_BRANCH) + actions.log(f"Successfully published to {RELEASES_BRANCH}") + _emit_published(repository, changed, source_branch) + return 0 + + +def _emit_published(repository: str, changed: str, source_branch: str) -> None: + """Emit one plugin.published event per changed plugin (best-effort, concurrent). + + Deliveries are independent and already non-fatal, so they run in parallel + rather than serializing one 15s timeout per plugin on the publish path. + """ + from concurrent.futures import ThreadPoolExecutor + + from ..integrations import webhooks + actor = os.environ.get("GITHUB_ACTOR", "") + source_sha = _git("rev-parse", f"origin/{source_branch}", check=False).stdout.strip() + from ..core import gh, version as version_util + all_tags = gh.release_list_tags(repository) + payloads = [] + for line in changed.splitlines(): + if "@" not in line: + continue + plugin_key, _, version = line.partition("@") + plugin = plugin_key.replace("_", "-") + commit_sha = "" + release_versions = [] + try: + with open(f"metadata/{plugin}/manifest.json", encoding="utf-8") as fh: + release_versions = (json.load(fh).get("manifest") or {}).get("versions") or [] + commit_sha = next((str(entry.get("commit_sha") or "") for entry in release_versions + if str(entry.get("version")) == version), "") + except (OSError, ValueError): + pass + pr_number, pr_url = gh.commit_pull_number(repository, commit_sha) if commit_sha else (None, None) + payload = webhooks.plugin_published(plugin, version, pr_number, actor) + tag = f"{plugin}-{version}" + payload["release_url"] = f"https://github.com/{repository}/releases/tag/{tag}" + asset_url = f"https://github.com/{repository}/releases/download/{tag}/{tag}.zip" + raw_manifest = _git("show", f"origin/{source_branch}:plugins/{plugin}/plugin.json", check=False).stdout + try: + plugin_manifest = json.loads(raw_manifest) + except json.JSONDecodeError: + plugin_manifest = {} + payload["description"] = str(plugin_manifest.get("description") or "") + payload["author"] = str(plugin_manifest.get("author") or "unknown") + upstream = str(plugin_manifest.get("repo_url") or "").rstrip("/") + external_upstream = plugin_manifest.get("source_type") == "external" and upstream.startswith("https://github.com/") + if not external_upstream: + upstream = f"https://github.com/{repository}" + links = [] + source_tag = f"v{version}" if external_upstream else tag + if external_upstream: + links.append(f"[v{version}]({upstream}/releases/tag/{source_tag})") + previous = [entry for entry in version_util.versioned_tags(all_tags, plugin) if entry != tag] + if previous: + previous_version = previous[0][len(plugin) + 1:] + links.append(f"[v{previous_version}...v{version}]({upstream}/compare/v{previous_version}...v{version})") + else: + if commit_sha: + links.append(f"[{commit_sha[:7]}]({upstream}/commit/{commit_sha})") + if pr_number and pr_url: + links.append(f"[#{pr_number}]({pr_url})") + payload["metadata"] = "-# " + " · ".join(links) + attachment = f"{plugin}-{version}.zip" + attachment_path = f"/tmp/{attachment}" + if os.path.isfile(attachment_path): + payload["attachment_filename"] = attachment + payload["attachment_path"] = attachment_path + for ext in ("png", "svg", "jpg", "webp"): + path = f"plugins/{plugin}/logo.{ext}" + if source_sha and _git("cat-file", "-e", f"origin/{source_branch}:{path}", check=False).returncode == 0: + payload["logo_url"] = f"https://raw.githubusercontent.com/{repository}/{source_sha}/{path}" + break + payloads.append(payload) + if not payloads: + return + with ThreadPoolExecutor(max_workers=min(8, len(payloads))) as pool: + for payload in payloads: + pool.submit(webhooks.emit, "plugin.published", payload) + webhooks.emit_discord("plugin.published", {"releases": payloads}, repository=repository) + for payload in payloads: + path = payload.get("attachment_path") + if path: + try: + os.remove(path) + except OSError: + pass + + +def _only_timestamps() -> bool: + """True when the staged diff is only README timestamp / manifest generated_at noise.""" + names = _git("diff", "--cached", "--name-only").stdout.splitlines() + for changed_file in names: + if changed_file == "README.md": + if _strip_lines(":README.md", "*Last updated:") != \ + _strip_lines("HEAD:README.md", "*Last updated:"): + return False + elif changed_file == "manifest.json": + if _strip_lines(":manifest.json", '"generated_at"') != \ + _strip_lines("HEAD:manifest.json", '"generated_at"'): + return False + else: + return False + return True + + +def _strip_lines(ref: str, needle: str) -> str: + out = _git("show", ref, check=False).stdout + return "\n".join(ln for ln in out.splitlines() if needle not in ln) + + +def _read(path: str) -> str: + try: + with open(path, encoding="utf-8") as fh: + return fh.read().strip() + except OSError: + return "" diff --git a/.github/pluginctl/src/pluginctl/publish/yank.py b/.github/pluginctl/src/pluginctl/publish/yank.py new file mode 100644 index 00000000..f6d20c68 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/yank.py @@ -0,0 +1,252 @@ +"""Yank a plugin version from the releases branch (port of yank-version.sh). + +Removes a specific version's GitHub Release, regenerates manifests/READMEs, and, +when the yanked version was the latest (or only) one, opens a rollback PR against +the source branch so it is not republished. Authorized by an open issue whose +number is cited and (for non-latest yanks) closed. Env-driven, validated by fork +e2e per the rollout plan. +""" + +from __future__ import annotations + +import glob +import json +import os +import subprocess +import tempfile + +from .. import label_catalog +from ..core import actions, gh +from ..core.git import configure_identity, run as _git +from ..core.version import sort_versions_desc +from . import manifest, readmes + +RELEASES_BRANCH = "releases" + + +def run() -> int: + repository = os.environ["GITHUB_REPOSITORY"] + token = os.environ["GITHUB_TOKEN"] + plugin = os.environ["YANK_PLUGIN"] + version = os.environ["YANK_VERSION"] + source_branch = os.environ["SOURCE_BRANCH"] + issue = os.environ["YANK_ISSUE"] + app_slug = os.environ.get("APP_SLUG", "") + remote = f"https://x-access-token:{token}@github.com/{repository}.git" + + if not label_catalog.ensure(repository): + return 1 + + actions.log(f"Yanking {plugin} v{version} from {RELEASES_BRANCH} (issue #{issue})") + + issue_state = gh.api(f"repos/{repository}/issues/{issue}", jq=".state") + if not issue_state: + actions.error(f"Issue #{issue} not found in {repository}.") + return 1 + if issue_state != "open": + actions.error(f"Issue #{issue} is already {issue_state}. Only open issues can authorize a yank.") + return 1 + actions.log(f" Issue #{issue} is open - proceeding.") + + workdir = tempfile.mkdtemp() + build_meta_dir = tempfile.mkdtemp() + os.environ["BUILD_META_DIR"] = build_meta_dir # kept for parity; generate() is passed it directly + start_cwd = os.getcwd() + try: + _git("clone", "--no-checkout", remote, f"{workdir}/repo") + os.chdir(f"{workdir}/repo") + configure_identity(app_slug) + + if _git("ls-remote", "--exit-code", "--heads", "origin", RELEASES_BRANCH, + check=False).returncode != 0: + actions.error("Releases branch does not exist.") + return 1 + _git("checkout", RELEASES_BRANCH) + _git("pull", "origin", RELEASES_BRANCH, check=False) + + release_tag = f"{plugin}-{version}" + plugin_manifest = f"metadata/{plugin}/manifest.json" + + if not gh.release_exists(release_tag, repository): + actions.error(f"GitHub Release {release_tag} not found. Nothing to yank.") + return 1 + + prefix = f"{plugin}-" + remaining = [t for t in gh.release_list_tags(repository) + if t.startswith(prefix) and t != f"{plugin}-latest" and t != release_tag] + is_last_version = len(remaining) == 0 + + current_latest = "" + if os.path.isfile(plugin_manifest): + try: + with open(plugin_manifest, encoding="utf-8") as fh: + data = json.load(fh) + current_latest = ((data.get("manifest") or {}).get("latest") or {}).get("version") or "" + except ValueError: + current_latest = "" + is_latest = version == current_latest + + actions.log(f" Current latest : {current_latest or 'unknown'}") + actions.log(f" Is latest : {str(is_latest).lower()}") + actions.log(f" Remaining releases: {len(remaining)}") + actions.log(f" Is last version : {str(is_last_version).lower()}") + + _git("fetch", "origin", source_branch) + _git("checkout", f"origin/{source_branch}", "--", "plugins") + source_type = "local" + pj = f"plugins/{plugin}/plugin.json" + if os.path.isfile(pj): + try: + with open(pj, encoding="utf-8") as fh: + source_type = (json.load(fh).get("source_type") or "local") + except ValueError: + source_type = "local" + + new_latest_version = "" + if is_last_version: + actions.log(f"Last version - deleting all GitHub Releases for {plugin}.") + gh.release_delete(release_tag, repository) + subprocess.run(["rm", "-rf", f"metadata/{plugin}"], check=False) + else: + actions.log(f"Deleting GitHub Release {release_tag}") + gh.release_delete(release_tag, repository) + if is_latest: + bare = [t[len(prefix):] for t in remaining] + ordered = sort_versions_desc(bare) + new_latest_version = ordered[0] if ordered else "" + if not new_latest_version: + actions.error("Could not find a replacement version to promote to latest.") + return 1 + actions.log(f"Promoting {new_latest_version} to latest (manifest will be updated by generate-manifest).") + + for f in ("manifest.json", "README.md"): + if os.path.exists(f): + os.remove(f) + actions.log("\n=== Regenerating manifests ===") + manifest.generate(source_branch, RELEASES_BRANCH, repository, build_meta_dir) + actions.log("\n=== Regenerating per-plugin READMEs ===") + readmes.generate_plugin_readmes(source_branch, repository) + actions.log("\n=== Regenerating releases README ===") + readmes.generate_releases_readme(source_branch, RELEASES_BRANCH, repository) + + releases_commit = _commit_releases(remote, plugin, version, issue) + rollback_pr_url = "" + if is_latest: + rollback_pr_url = _open_rollback_pr( + repository, source_branch, remote, plugin, version, + new_latest_version, is_last_version, source_type, issue) + + _finalize_issue(repository, plugin, version, issue, releases_commit, + is_latest, rollback_pr_url) + from ..integrations import webhooks + webhooks.emit("plugin.yanked", + webhooks.plugin_yanked(plugin, version, issue, rollback_pr_url or None)) + return 0 + finally: + os.chdir(start_cwd) + subprocess.run(["rm", "-rf", workdir, build_meta_dir], check=False) + + +def _commit_releases(remote, plugin, version, issue) -> str: + actions.log("\n=== Committing ===") + subprocess.run(["rm", "-rf", "plugins"], check=False) + _git("rm", "-rf", "--cached", "plugins", check=False) + _git("add", "metadata", "manifest.json", "README.md") + if _git("diff", "--cached", "--quiet", check=False).returncode == 0: + actions.log("No changes to commit - was this version already absent?") + return "" + _git("commit", "-m", f"Yank {plugin} v{version}\n\nRefs #{issue}") + releases_commit = _git("rev-parse", "--short", "HEAD").stdout.strip() + _git("push", remote, RELEASES_BRANCH) + actions.log(f"Successfully yanked {plugin} v{version} from {RELEASES_BRANCH}") + return releases_commit + + +def _open_rollback_pr(repository, source_branch, remote, plugin, version, + new_latest_version, is_last_version, source_type, issue) -> str: + actions.log("\n=== Opening rollback PR ===") + rollback_branch = f"yank/{plugin}-{version}" + _git("fetch", "origin", source_branch) + _git("checkout", "-b", rollback_branch, f"origin/{source_branch}") + + if is_last_version: + _git("rm", "-rf", f"plugins/{plugin}") + pr_title = f"[{plugin}]: Remove plugin (all versions yanked)" + pr_body = (f"## Plugin Removed\n\nAll published versions of `{plugin}` have been yanked " + f"from the releases branch.\n\nThis PR removes the plugin from the source branch " + f"to prevent it from being republished on the next run.\n\n" + f"**Yanked version:** `{version}`\n**Authorized by:** #{issue}\n\nCloses #{issue}") + else: + source_note = _rollback_source(plugin, new_latest_version, source_type) + _git("add", f"plugins/{plugin}/") + pr_title = f"[{plugin}]: Roll back to v{new_latest_version} (yank of v{version})" + pr_body = (f"## Version Rollback\n\n`{plugin}` `{version}` has been yanked from the releases " + f"branch. This PR rolls the source back to `{new_latest_version}` so the yanked " + f"version is not republished on the next run.\n\n" + f"**Yanked version:** `{version}`\n**New latest:** `{new_latest_version}`\n" + f"**Authorized by:** #{issue}\n\n{source_note}\n\nCloses #{issue}") + + _git("commit", "-m", pr_title) + _git("push", remote, rollback_branch) + url = gh.pr_create(repository, source_branch, rollback_branch, pr_title, pr_body, + labels=["Rollback"]) or "" + actions.log(f"Rollback PR opened: {rollback_branch} -> {source_branch} ({url})") + return url + + +def _rollback_source(plugin, new_latest_version, source_type) -> str: + pj = f"plugins/{plugin}/plugin.json" + if source_type == "local": + restore_commit = "" + shas = _git("log", "--all", "--format=%H", "--", pj).stdout.split() + for sha in shas: + show = _git("show", f"{sha}:{pj}", check=False) + if show.returncode != 0: + continue + try: + if json.loads(show.stdout).get("version") == new_latest_version: + restore_commit = sha + break + except ValueError: + continue + if restore_commit: + actions.log(f"Restoring plugins/{plugin}/ from commit {restore_commit}") + _git("checkout", restore_commit, "--", f"plugins/{plugin}/") + return (f"Plugin source files restored from commit `{restore_commit[:7]}` " + f"(the last commit where `{plugin}` was at `{new_latest_version}`).") + actions.warning(f"Could not find a commit for {plugin} v{new_latest_version} - " + "falling back to version field update only.") + _bump_version(pj, new_latest_version) + return ("⚠️ **Could not restore source files** - no commit found for " + f"`{new_latest_version}` (history may have been squashed). Only the `version` " + "field was updated. Please review the source files manually before merging.") + _bump_version(pj, new_latest_version) + return (f"Version field in `plugin.json` updated to `{new_latest_version}`. The ZIP will be " + "re-fetched from the external source URL on the next publish run.") + + +def _bump_version(pj: str, new_version: str) -> None: + with open(pj, encoding="utf-8") as fh: + data = json.load(fh) + data["version"] = new_version + # jq '.version = $v' rewrites pretty-printed with 2-space indent + trailing newline. + with open(pj, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + +def _finalize_issue(repository, plugin, version, issue, releases_commit, + is_latest, rollback_pr_url) -> None: + commit_label = releases_commit or "unknown" + if is_latest: + gh.issue_comment(issue, repository, + f"`{plugin}` v`{version}` has been removed from the releases branch " + f"(commit `{commit_label}`).\n\nA rollback PR has been opened to update the " + f"source branch: {rollback_pr_url or '(see workflow run for link)'}\n\n" + "This issue will close automatically when that PR is merged.") + else: + gh.issue_comment(issue, repository, + f"`{plugin}` v`{version}` has been removed from the releases branch " + f"(commit `{commit_label}`).\n\nThis was not the latest version so no source " + "branch changes are needed.") + gh.issue_close(issue, repository, reason="completed") diff --git a/.github/pluginctl/src/pluginctl/publish/zips.py b/.github/pluginctl/src/pluginctl/publish/zips.py new file mode 100644 index 00000000..13a5db95 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/zips.py @@ -0,0 +1,143 @@ +"""Build versioned ZIPs + GitHub Releases (port of build-zips.sh). + +Runs from the releases-branch checkout directory. For every plugin whose current +version has no GitHub Release yet, it builds (or downloads, for external plugins) +the ZIP, records per-version metadata under ``BUILD_META_DIR`` for +generate-manifest to consume, uploads the release, and appends the plugin to +``changed_plugins.txt``. +""" + +from __future__ import annotations + +import glob +import json +import os +import shutil +import subprocess +import tempfile +import time +import urllib.request +from typing import Optional + +from ..core import actions, gh, git, jsonio +from ..core.hashing import file_digests +from ..core.jsonio import drop_none +from ..core.timeutil import now_iso +from ..validate.detect import is_safe_name + + +def _download(url: str, dest: str, attempts: int = 3) -> bool: + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(url, timeout=60) as resp, open(dest, "wb") as out: + shutil.copyfileobj(resp, out) + return True + except Exception: + if os.path.exists(dest): + os.remove(dest) + if attempt < attempts: + actions.log(f" Download attempt {attempt} failed, retrying in 15s...") + time.sleep(15) + return False + + +def _commit_info(source_branch: str, plugin_dir: str) -> tuple[str, str, str]: + """(commit_sha, commit_sha_short, commit_date) of the last commit touching the plugin.""" + out = git.log_format("%H%n%h%n%cI", f"origin/{source_branch}", plugin_dir) + if not out: + return "", "", "" + fields = out.splitlines() + fields += [""] * (3 - len(fields)) + return fields[0], fields[1], fields[2] + + +def run(source_branch: str, repository: str, build_meta_dir: str) -> int: + changed: list[str] = [] + open("changed_plugins.txt", "w", encoding="utf-8").close() + + for plugin_dir in sorted(glob.glob("plugins/*/")): + plugin_name = os.path.basename(plugin_dir.rstrip("/")) + if not is_safe_name(plugin_name): + actions.warning(f"Skipping publish for unsafe plugin folder name: '{plugin_name}'") + continue + plugin_key = plugin_name.replace("-", "_") + with open(os.path.join(plugin_dir, "plugin.json"), encoding="utf-8") as fh: + raw = json.load(fh) + version = str(raw.get("version")) + os.makedirs(f"metadata/{plugin_name}", exist_ok=True) + + zip_path = f"/tmp/{plugin_name}-{version}.zip" + release_tag = f"{plugin_name}-{version}" + + if gh.release_exists(release_tag, repository): + actions.log(f" {plugin_name} v{version} - skipping (release already exists)") + continue + + source_type = raw.get("source_type") or "local" + build_timestamp = now_iso() + commit_sha, commit_sha_short, commit_date = _commit_info(source_branch, plugin_dir) + last_updated = commit_date or now_iso() + source_url_resolved = "" + + if source_type == "external": + source_url_resolved = str(raw.get("source_url") or "").replace("{version}", version) + actions.log(f" {plugin_name} v{version} - fetching external ZIP from {source_url_resolved}") + changed.append(f"{plugin_key}@{version}") + if not _download(source_url_resolved, zip_path): + actions.error(f"Failed to download external ZIP from {source_url_resolved} after 3 attempts") + return 1 + else: + actions.log(f" {plugin_name} v{version} - building") + changed.append(f"{plugin_key}@{version}") + with tempfile.TemporaryDirectory() as tmpdir: + shutil.copytree(f"plugins/{plugin_name}", os.path.join(tmpdir, plugin_key)) + subprocess.run(["zip", "-r", zip_path, plugin_key], cwd=tmpdir, + check=True, stdout=subprocess.DEVNULL) + + checksum_md5, checksum_sha256 = file_digests(zip_path, "md5", "sha256") + min_da = str(raw.get("min_dispatcharr_version") or "") + max_da = str(raw.get("max_dispatcharr_version") or "") + zip_size_kb = os.path.getsize(zip_path) // 1024 + + meta = drop_none({ + "version": version, + "commit_sha": commit_sha or None, + "commit_sha_short": commit_sha_short or None, + "build_timestamp": build_timestamp, + "last_updated": last_updated, + "checksum_md5": checksum_md5, + "checksum_sha256": checksum_sha256, + "min_dispatcharr_version": min_da or None, + "max_dispatcharr_version": max_da or None, + "source_url": source_url_resolved or None, + "size_kb": zip_size_kb, + }) + os.makedirs(os.path.join(build_meta_dir, plugin_key), exist_ok=True) + with open(os.path.join(build_meta_dir, plugin_key, f"{plugin_key}-{version}.json"), + "w", encoding="utf-8") as fh: + fh.write(jsonio.dumps(meta)) + + notes = _release_notes(repository, plugin_name, commit_sha, commit_sha_short) + actions.log(f" {plugin_name} v{version} - uploading to GitHub Releases") + gh.release_create(release_tag, repository, f"{plugin_name} v{version}", notes, zip_path) + + with open("changed_plugins.txt", "w", encoding="utf-8") as fh: + for line in changed: + fh.write(line + "\n") + actions.log(f"Built {len(changed)} new/updated plugin(s).") + return 0 + + +def _release_notes(repository: str, plugin_name: str, commit_sha: str, + commit_sha_short: str) -> str: + readme_url = f"https://github.com/{repository}/blob/releases/metadata/{plugin_name}/README.md" + notes = "" + if commit_sha: + commit_url = f"https://github.com/{repository}/commit/{commit_sha}" + notes = f"**Commit:** [`{commit_sha_short}`]({commit_url})" + pr_number, pr_url = gh.commit_pull_number(repository, commit_sha) + if pr_number: + notes += f"\n**PR:** [#{pr_number}]({pr_url})" + notes += "\n" + notes += f"**README:** [Plugin README]({readme_url})" + return notes diff --git a/.github/pluginctl/src/pluginctl/validate/__init__.py b/.github/pluginctl/src/pluginctl/validate/__init__.py new file mode 100644 index 00000000..80fa5f5c --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/__init__.py @@ -0,0 +1 @@ +"""PR-time validation pipeline: detect, checks, SARIF, report, gate.""" diff --git a/.github/pluginctl/src/pluginctl/validate/clamav.py b/.github/pluginctl/src/pluginctl/validate/clamav.py new file mode 100644 index 00000000..201d5b20 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/clamav.py @@ -0,0 +1,74 @@ +"""``pluginctl clamav-report``: parse clamscan output into status + findings table. + +Ports the "Set ClamAV status outputs" step of the clamav-scan job. ClamAV itself +still runs as the native CLI in the workflow; only the result parsing moves here. +""" + +from __future__ import annotations + +import os +from typing import Callable, Optional + +from ..core import actions +from ..core.hashing import file_digests + + +def _sha256_file(path: str) -> str: + try: + return file_digests(path, "sha256")[0] + except OSError: + return "" + + +def parse_infected_lines(output: str) -> list[str]: + """Lines ending in ' FOUND' (clamscan --infected format).""" + return [ln for ln in output.splitlines() if ln.endswith(" FOUND")] + + +def build_findings_table(infected_lines: list[str], workspace: str, + sha256_fn: Optional[Callable[[str], str]] = None) -> str: + """Render the ``| File | Signature |`` table, linking known hashes to VirusTotal.""" + sha256_fn = sha256_fn or _sha256_file + rows = ["| File | Signature |", "|------|-----------|"] + for line in infected_lines: + # ": FOUND" + full_path = line + idx = line.rfind(": ") + if idx != -1 and line.endswith(" FOUND"): + full_path = line[:idx] + sig = line[idx + 2:-len(" FOUND")] + else: + sig = "" + file_rel = full_path + prefix = (workspace or "") + "/" + if workspace and file_rel.startswith(prefix): + file_rel = file_rel[len(prefix):] + digest = sha256_fn(full_path) + if digest: + rows.append(f"| `{file_rel}` | [`{sig}`](https://www.virustotal.com/gui/file/{digest}) |") + else: + rows.append(f"| `{file_rel}` | `{sig}` |") + return "\n".join(rows) + "\n" + + +def run(output_file: str = "clamav-output.txt", scan_exit: int = 0, + findings_out: str = "clamav-findings.md") -> int: + workspace = os.environ.get("GITHUB_WORKSPACE", "") + output = "" + if os.path.isfile(output_file): + with open(output_file, encoding="utf-8") as fh: + output = fh.read() + infected_lines = parse_infected_lines(output) + actions.set_output("clamav_infected", str(len(infected_lines))) + + if infected_lines: + table = build_findings_table(infected_lines, workspace) + with open(findings_out, "w", encoding="utf-8") as fh: + fh.write(table) + actions.set_output("clamav_status", "failure") + return 0 + if scan_exit >= 2: + actions.set_output("clamav_status", "failure") + return 0 + actions.set_output("clamav_status", "success") + return 0 diff --git a/.github/pluginctl/src/pluginctl/validate/detect.py b/.github/pluginctl/src/pluginctl/validate/detect.py new file mode 100644 index 00000000..053c5fcc --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/detect.py @@ -0,0 +1,249 @@ +"""``pluginctl detect``: port of validate/detect-changes.sh + inline blacklist. + +Determines the changed-plugin matrix, whether the PR must be auto-closed, whether +it touches files outside ``plugins/`` without authorization, new/updated flags, +and the signing-key-changed warning. Emits the same ``$GITHUB_OUTPUT`` keys the +Bash script did so the downstream jobs are unchanged. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Optional + +from ..core import actions, gh, git + +SAFE_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +PUB_KEY_PATH = ".github/scripts/keys/dispatcharr-plugins.pub" +METADATA_ONLY_FIELDS = { + "description", "repo_url", "discord_thread", "min_dispatcharr_version", + "max_dispatcharr_version", "deprecated", "unlisted", "maintainers", "ai_assisted", +} + + +def is_safe_name(name: str) -> bool: + return bool(SAFE_NAME_RE.match(name)) + + +def author_in_plugin_json(pr_author: str, data: dict) -> bool: + """True when pr_author is the plugin's ``author`` or one of its ``maintainers``.""" + author = data.get("author") or "" + maintainers = [str(m) for m in (data.get("maintainers") or []) if m is not None] + return pr_author == author or pr_author in maintainers + + +def author_has_plugin_permission(pr_author: str, base_json_text: Optional[str]) -> bool: + """True when pr_author is the base-branch author or in base maintainers. + + Reads from the base branch only, so a PR cannot self-grant permission. + """ + if not base_json_text: + return False + try: + data = json.loads(base_json_text) + except json.JSONDecodeError: + return False + return author_in_plugin_json(pr_author, data) + + +def changed_json_fields(old: dict, new: dict) -> set[str]: + """Return manifest keys whose value changed, including additions and removals.""" + return {key for key in old.keys() | new.keys() if old.get(key) != new.get(key)} + + +def is_metadata_only_update(plugin_name: str, base_ref: str, changed_files: list[str]) -> bool: + """Whether an existing plugin only changes approved metadata in plugin.json.""" + plugin_json_path = f"plugins/{plugin_name}/plugin.json" + if changed_files != [plugin_json_path]: + return False + + base_json_text = git.show(f"origin/{base_ref}:{plugin_json_path}") + if base_json_text is None: + return False + try: + with open(plugin_json_path, encoding="utf-8") as fh: + current = json.load(fh) + base = json.loads(base_json_text) + except (OSError, json.JSONDecodeError): + return False + return bool(changed_json_fields(base, current)) and changed_json_fields(base, current) <= METADATA_ONLY_FIELDS + + +@dataclass +class BlacklistResult: + matched: bool = False + reason: str = "" + + +def check_blacklists(pr_author: str, plugins: list[str], + author_blacklist: str, plugin_blacklist: str) -> BlacklistResult: + """Case-insensitive author/plugin blacklist check (whitespace-trimmed entries).""" + author_bl = (author_blacklist or "").strip() + plugin_bl = (plugin_blacklist or "").strip() + + if author_bl and _csv_contains(pr_author, author_bl): + actions.warning(f"PR author '{pr_author}' is on the author blacklist.") + return BlacklistResult(True, "author-blacklisted") + + if plugin_bl: + for plugin in plugins: + if plugin and _csv_contains(plugin, plugin_bl): + actions.warning(f"Plugin '{plugin}' is on the plugin blacklist.") + return BlacklistResult(True, "plugin-blacklisted") + return BlacklistResult() + + +def _csv_contains(value: str, csv: str) -> bool: + """Case-insensitive membership in a comma-separated list (spaces stripped).""" + return any(value.lower() == entry.replace(" ", "").lower() for entry in csv.split(",")) + + +def run(pr_author: str, base_ref: str, head_ref: str = "", + author_blacklist: str = "", plugin_blacklist: str = "", + repo: str = "") -> int: + """Execute detection; write outputs; return process exit code (0 ok, 1 hard block).""" + owner, _, name = repo.partition("/") + cached_write_access: Optional[bool] = None + + def write_access() -> bool: + """``gh api`` permission lookup, resolved at most once per run.""" + nonlocal cached_write_access + if cached_write_access is None: + cached_write_access = gh.has_write_access(owner, name, pr_author) + return cached_write_access + + # Bot-authored yank rollback PRs bypass plugin validation entirely. + if pr_author.endswith("[bot]") and head_ref.startswith("yank/"): + actions.log(f"Bot-authored yank rollback PR detected ({pr_author}, {head_ref}) " + "- skipping plugin validation.") + actions.set_outputs(matrix="[]", plugin_count="0", close_pr="false", + close_reason="", skip_validation="true", + outside_violation="false", pub_key_changed="false", + has_new_plugin="false", has_updated_plugin="false", + metadata_only="false") + return 0 + + merge_base = git.merge_base(f"origin/{base_ref}", "HEAD") + changed = git.diff_name_only(merge_base, "HEAD") + outside_changes = [f for f in changed if not f.startswith("plugins/")] + has_outside_violation = bool(outside_changes) and not write_access() + + plugin_list = sorted({f.split("/")[1] for f in changed if f.startswith("plugins/") and len(f.split("/")) > 1}) + + if not plugin_list: + return _no_plugins(pr_author, outside_changes, has_outside_violation, write_access) + + # Allowlist: only safe kebab-case names enter the matrix. A folder with an + # unsafe name is never scanned (ClamAV/CodeQL/publish all iterate the safe + # matrix), so its presence must block the whole PR rather than being + # silently dropped - otherwise it can ride along with a legitimate, + # passing plugin change straight to `main` and publish unscanned. + safe_list: list[str] = [] + unsafe_list: list[str] = [] + for plugin in plugin_list: + if is_safe_name(plugin): + safe_list.append(plugin) + else: + unsafe_list.append(plugin) + actions.warning(f"Unsafe plugin folder name: '{plugin}'") + plugin_list = safe_list + + if unsafe_list: + actions.error(f"Unsafe plugin folder name(s) detected: {', '.join(unsafe_list)}") + actions.set_outputs(close_pr="true", close_reason="unsafe-plugin-name", + plugin_count="0", matrix="[]", + has_new_plugin="false", has_updated_plugin="false", + metadata_only="false") + return 0 + + if not plugin_list: + actions.error("No valid plugin changes detected in this PR.") + actions.set_outputs(close_pr="true", close_reason="no-valid-plugins", + plugin_count="0", matrix="[]", + has_new_plugin="false", has_updated_plugin="false", + metadata_only="false") + return 0 + + plugin_count = len(plugin_list) + + has_new_plugin = False + has_updated_plugin = False + for plugin in plugin_list: + if git.object_exists(f"origin/{base_ref}:plugins/{plugin}/plugin.json"): + has_updated_plugin = True + else: + has_new_plugin = True + + metadata_only = all( + is_metadata_only_update( + plugin, + base_ref, + sorted(f for f in changed if f.startswith(f"plugins/{plugin}/")), + ) + for plugin in plugin_list + ) + + is_maintainer = write_access() + has_any_permission = is_maintainer + if not is_maintainer: + for plugin in plugin_list: + base_json = git.show(f"origin/{base_ref}:plugins/{plugin}/plugin.json") + if base_json and author_has_plugin_permission(pr_author, base_json): + has_any_permission = True + break + + close_pr = (not has_any_permission) and (not has_new_plugin) + + # Blacklist gate (only meaningful when not already closing). + close_reason = "unauthorized" if close_pr else "" + if not close_pr: + bl = check_blacklists(pr_author, plugin_list, author_blacklist, plugin_blacklist) + if bl.matched: + close_pr = True + close_reason = bl.reason + + actions.set_outputs(matrix=json.dumps(plugin_list, separators=(",", ":")), + plugin_count=str(plugin_count), + close_pr="true" if close_pr else "false", + skip_validation="false", + outside_violation="true" if has_outside_violation else "false", + close_reason=close_reason, + metadata_only="true" if metadata_only else "false") + if outside_changes: + actions.set_output("outside_files", "\n".join(outside_changes)) + + pub_key_changed = is_maintainer and PUB_KEY_PATH in outside_changes + actions.set_outputs(pub_key_changed="true" if pub_key_changed else "false", + has_new_plugin="true" if has_new_plugin else "false", + has_updated_plugin="true" if has_updated_plugin else "false") + + actions.log(f"Detected {plugin_count} plugin(s): {' '.join(plugin_list)}") + actions.log(f"close_pr={'true' if close_pr else 'false'}") + return 0 + + +def _no_plugins(pr_author, outside_changes, has_outside_violation, write_access) -> int: + if has_outside_violation: + actions.set_outputs(matrix="[]", plugin_count="0", close_pr="false", + close_reason="", skip_validation="false", + outside_violation="true", + has_new_plugin="false", has_updated_plugin="false", + metadata_only="false") + actions.set_output("outside_files", "\n".join(outside_changes)) + return 0 + if write_access(): + pub_key_changed = PUB_KEY_PATH in outside_changes + actions.set_outputs(matrix="[]", plugin_count="0", close_pr="false", + close_reason="", skip_validation="true", + outside_violation="false", + pub_key_changed="true" if pub_key_changed else "false", + has_new_plugin="false", has_updated_plugin="false", + metadata_only="false") + if outside_changes: + actions.set_output("outside_files", "\n".join(outside_changes)) + actions.log("No plugin changes detected - skipping plugin validation (author has write access).") + return 0 + actions.error("No plugin changes detected in this PR.") + return 1 diff --git a/.github/pluginctl/src/pluginctl/validate/gate.py b/.github/pluginctl/src/pluginctl/validate/gate.py new file mode 100644 index 00000000..d2f71ec5 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/gate.py @@ -0,0 +1,48 @@ +"""``pluginctl gate``: port of the ``Plugin PR Check`` evaluation ladder. + +This is the single fixed status check referenced by branch protection. The +decision order and messages are preserved exactly; the quarantine-label check is +a separate step that runs first in the workflow and is modeled here too. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class GateResult: + ok: bool + message: str + + +def evaluate(detect_result: str, close_pr: str, skip_validation: str, + outside_violation: str, title_result: str, + codeql_result: str, codeql_status: str, + clamav_result: str, clamav_status: str, + validate_result: str, report_result: str, + test_result: str = "skipped") -> GateResult: + """Return pass/fail with the exact ::error:: message the Bash ladder emits.""" + if detect_result != "success": + return GateResult(False, "Plugin detection failed or no plugin changes found.") + # Tooling test suite gates repo-update PRs. Checked before the skip_validation + # early-pass so a pure repo update cannot merge with red tests. + if test_result in ("failure", "cancelled"): + return GateResult(False, "Tooling test suite failed. See the Test Suite job for details.") + if skip_validation == "true": + return GateResult(True, "No plugin changes detected and author has write access - passing.") + if title_result == "failure": + return GateResult(False, "PR title does not match the required format. Rename the PR and re-run.") + if outside_violation == "true": + return GateResult(False, "PR contains unauthorized changes outside the plugins/ directory.") + if close_pr == "true": + return GateResult(False, "PR is unauthorized - no permission to modify these plugins.") + if codeql_result == "failure" or codeql_status == "failure": + return GateResult(False, "CodeQL security analysis failed. See the Security tab for details.") + if clamav_result == "failure" or clamav_status == "failure": + return GateResult(False, "ClamAV antivirus scan detected threats. See PR comment for details.") + if validate_result in ("failure", "cancelled"): + return GateResult(False, "One or more plugin validations failed. See PR comment for details.") + if report_result != "success": + return GateResult(False, "Plugin validation failed. See PR comment for details.") + return GateResult(True, "All plugins validated successfully.") diff --git a/.github/pluginctl/src/pluginctl/validate/labels.py b/.github/pluginctl/src/pluginctl/validate/labels.py new file mode 100644 index 00000000..fba04fd8 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/labels.py @@ -0,0 +1,38 @@ +"""``pluginctl label``: port of the inline ``label-pr`` job. + +Reconciles the classification labels on a PR. The decision is pure and unit +tested; applying it just calls ``gh pr edit --add-label/--remove-label`` with the +same add-when-true / remove-when-false behavior as the Bash ``apply_label``. +""" + +from __future__ import annotations + +from .. import label_catalog +from ..core import gh + +LABELS = ["New Plugin", "Plugin Update", "Repo Update", "Invalid"] + + +def decide_labels(has_new_plugin: bool, has_updated_plugin: bool, + outside_files: str, outside_violation: bool, + close_pr: bool) -> dict[str, bool]: + """Return the desired on/off state for each managed label.""" + is_repo_update = bool(outside_files) and not outside_violation + is_invalid = outside_violation or close_pr + return { + "New Plugin": has_new_plugin, + "Plugin Update": has_updated_plugin, + "Repo Update": is_repo_update, + "Invalid": is_invalid, + } + + +def run(pr_number: str, has_new_plugin: bool, has_updated_plugin: bool, + outside_files: str, outside_violation: bool, close_pr: bool) -> int: + if not label_catalog.ensure(): + return 1 + desired = decide_labels(has_new_plugin, has_updated_plugin, + outside_files, outside_violation, close_pr) + for label in LABELS: + gh.pr_edit_label(pr_number, label, desired[label]) + return 0 diff --git a/.github/pluginctl/src/pluginctl/validate/langs.py b/.github/pluginctl/src/pluginctl/validate/langs.py new file mode 100644 index 00000000..622eb4d7 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/langs.py @@ -0,0 +1,79 @@ +"""``pluginctl detect-langs``: which CodeQL languages are present in changed plugins. + +Ports the "Detect supported languages" bash step. Emits ``found`` / ``languages`` +/ ``unscanned_langs`` the same way, in the same order, so the CodeQL matrix and +the "unscanned file types" notice are unchanged. +""" + +from __future__ import annotations + +import os + +from ..core import actions + +# CodeQL-supported languages, in the Bash detection order. +_SUPPORTED = [ + ("python", {".py"}), + ("javascript", None), # special-cased (prunes vendored dirs) + ("go", {".go"}), + ("ruby", {".rb"}), + ("java-kotlin", {".java", ".kt", ".kts"}), + ("c-cpp", {".c", ".cpp", ".cc", ".h", ".hpp"}), +] + +# Present-but-unsupported file types, in the Bash order. +_UNSCANNED = [ + ("shell", {".sh", ".bash"}), + ("php", {".php"}), + ("lua", {".lua"}), + ("perl", {".pl", ".pm"}), + ("rust", {".rs"}), +] + +# Directories pruned from the JavaScript search (vendored / build output). +_JS_PRUNE = {"node_modules", "dist", "build", "static"} +_JS_EXTS = {".js", ".ts", ".jsx", ".tsx"} + + +def _has_ext(root: str, exts: set[str]) -> bool: + for _dirpath, _dirs, files in os.walk(root): + for fn in files: + if os.path.splitext(fn)[1] in exts: + return True + return False + + +def _has_js(root: str) -> bool: + for dirpath, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if d not in _JS_PRUNE] + for fn in files: + if os.path.splitext(fn)[1] in _JS_EXTS: + return True + return False + + +def detect(root: str = "plugins") -> tuple[bool, str, str]: + """Return (found, languages_csv, unscanned_csv).""" + langs: list[str] = [] + for name, exts in _SUPPORTED: + if name == "javascript": + if _has_js(root): + langs.append(name) + elif _has_ext(root, exts): + langs.append(name) + unscanned = [name for name, exts in _UNSCANNED if _has_ext(root, exts)] + return bool(langs), ",".join(langs), ",".join(unscanned) + + +def run(root: str = "plugins") -> int: + found, languages, unscanned = detect(root) + actions.set_output("unscanned_langs", unscanned) + actions.set_output("found", "true" if found else "false") + actions.set_output("languages", languages) + if found: + actions.log(f"Detected languages for CodeQL: {languages}") + else: + actions.log("No supported language files found in changed plugins, skipping CodeQL.") + if unscanned: + actions.log(f"Unscanned file types (no CodeQL support): {unscanned}") + return 0 diff --git a/.github/pluginctl/src/pluginctl/validate/plugin.py b/.github/pluginctl/src/pluginctl/validate/plugin.py new file mode 100644 index 00000000..f5486d4d --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/plugin.py @@ -0,0 +1,394 @@ +"""``pluginctl validate``: port of validate/validate.sh. + +Validates one plugin and writes a markdown report fragment identical to the Bash +output: same ``### Plugin:`` header, description/repo subtext, the +``| Check | Status | Details |`` table (rows hidden when they pass, in the same +order), the optional release/compare links, and the trailing ```` +tab-separated marker consumed by ``report``. Emits ``result``/``is_new``/ +``has_permission`` outputs and exits non-zero on failure. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.request +from typing import Optional + +from ..core import actions, gh, git +from ..core.version import is_semver, is_dispatcharr_version, version_greater_than +from .detect import METADATA_ONLY_FIELDS, author_in_plugin_json, changed_json_fields, is_safe_name + +_GH_DOWNLOAD_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/") + +SPDX_URL = "https://raw.githubusercontent.com/spdx/license-list-data/main/json/licenses.json" + + +# ---- jq-parity scalar helpers ------------------------------------------------ +def jq_r(value, default: Optional[str] = None) -> str: + """Mimic ``jq -r``. Without a default, null/missing renders as ``"null"``. + + With ``default`` (the ``// "x"`` idiom), null/missing renders as ``default``. + """ + if value is None: + return "null" if default is None else default + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def field_present(raw: dict, key: str) -> bool: + """Reproduce ``jq -e '.\"key\"'``: present unless null or false.""" + val = raw.get(key, None) + return not (val is None or val is False) + + +def _single_line(s: str) -> str: + """Collapse newlines/carriage returns so a free-text field can't inject + extra fragment lines (e.g. a forged ```` marker).""" + return s.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") + + +def tsv(fields: list[str]) -> str: + """Reproduce ``@tsv`` escaping for a single row.""" + def esc(s: str) -> str: + return (s.replace("\\", "\\\\").replace("\t", "\\t") + .replace("\n", "\\n").replace("\r", "\\r")) + return "\t".join(esc(f) for f in fields) + + +# ---- network (injectable for tests) ----------------------------------------- +def http_status(url: str) -> str: + """Follow redirects, return the numeric HTTP status as a string ("000" on error).""" + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=15) as resp: + return str(getattr(resp, "status", None) or resp.getcode()) + except urllib.error.HTTPError as exc: # type: ignore[attr-defined] + return str(exc.code) + except Exception: + return "000" + + +def fetch_spdx() -> Optional[dict]: + try: + with urllib.request.urlopen(SPDX_URL, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception: + return None + + +# ---- helper: source file count (non-external) ------------------------------- +def count_source_files(plugin_dir: str) -> int: + ignore = {"plugin.json", "README.md", "logo.png"} + count = 0 + for root, _dirs, files in os.walk(plugin_dir): + for fn in files: + if fn not in ignore: + count += 1 + return count + + +def run(plugin_name: str, pr_author: str, base_ref: str, output_file: str, + repo: str = "") -> int: + owner, _, name = repo.partition("/") + plugin_dir = f"plugins/{plugin_name}" + plugin_json_path = f"{plugin_dir}/plugin.json" + + failed = False + is_new = False + has_permission = False + rows: list[str] = [] + pre_lines: list[str] = [] # header + desc/repo subtext + post_lines: list[str] = [] # release/compare link + meta_row: Optional[str] = None + + pre_lines.append(f"### Plugin: `{plugin_name}`") + pre_lines.append("") + + raw: Optional[dict] = None + if os.path.isfile(plugin_json_path): + try: + with open(plugin_json_path, encoding="utf-8") as fh: + raw = json.load(fh) + except (OSError, json.JSONDecodeError): + raw = None + if raw is not None: + # Free-text fields are attacker-controlled. Collapse newlines before + # embedding them in the fragment so a value can never introduce a + # forged "" marker line ahead of the real one + # that `report.parse_fragments` looks for. + desc = _single_line(jq_r(raw.get("description"), "")) + repo_url = jq_r(raw.get("repo_url"), "") + if desc: + pre_lines.append(f"_{desc}_") + pre_lines.append("") + if repo_url: + pre_lines.append(f"[Source Repository]({repo_url})") + pre_lines.append("") + + def emit_fragment() -> None: + lines = list(pre_lines) + lines.append("| Check | Status | Details |") + lines.append("|-------|:------:|---------|") + lines.extend(rows) + lines.append("") + lines.extend(post_lines) + if meta_row is not None: + lines.append(meta_row) + text = "\n".join(lines) + "\n" + _write(output_file, text) + + # Folder name format + if not is_safe_name(plugin_name): + rows.append(f"| Folder name | ❌ | Must be lowercase-kebab-case - got " + f"`{plugin_name}`, e.g. `my-plugin-name` |") + failed = True + + # plugin.json existence (early exit) + if not os.path.isfile(plugin_json_path): + rows.append("| `plugin.json` | ❌ | File missing |") + emit_fragment() + actions.set_outputs(result="fail", is_new="false", has_permission="false") + return 0 + + # JSON syntax (early exit) + if raw is None: + rows.append("| JSON syntax | ❌ | Invalid JSON in plugin.json |") + emit_fragment() + actions.set_outputs(result="fail", is_new="false", has_permission="false") + return 0 + + # Required fields + missing = [f"`{key}`" for key in ("name", "version", "description") + if not field_present(raw, key)] + if missing: + failed = True + rows.append(f"| Required fields | ❌ | Missing: {', '.join(missing)} |") + else: + rows.append("| Required fields | ✅ | All required fields present |") + + author = jq_r(raw.get("author"), "") + maintainers = [str(m) for m in (raw.get("maintainers") or []) if m is not None] + maintainers_sp = " ".join(maintainers) + version = jq_r(raw.get("version")) + + # Base branch plugin.json (for version bump + compare link) + base_json_text = git.show(f"origin/{base_ref}:{plugin_json_path}") + base_raw = None + old_version = "" + old_source_url_tmpl = "" + if base_json_text is not None: + try: + base_raw = json.loads(base_json_text) + old_version = jq_r(base_raw.get("version"), "") + old_source_url_tmpl = jq_r(base_raw.get("source_url"), "") + except json.JSONDecodeError: + base_raw = None + + # External source checks + source_type = jq_r(raw.get("source_type"), "local") + release_link = "" + compare_link = "" + gh_tag = "" + old_gh_tag = "" + if source_type != "external": + if count_source_files(plugin_dir) == 0: + rows.append(f"| Plugin files | ❌ | No source files found in " + f"`plugins/{plugin_name}/`. Add your plugin source (e.g. `main.py`) " + f"or set `\"source_type\": \"external\"` in `plugin.json` if your plugin " + f"is hosted elsewhere |") + failed = True + else: + ext_source_url = jq_r(raw.get("source_url"), "") + ext_repo_url = jq_r(raw.get("repo_url"), "") + if not ext_source_url: + rows.append("| `source_url` | ❌ | Required when `source_type` is `external` |") + failed = True + elif not ext_source_url.startswith("https://"): + rows.append("| `source_url` | ❌ | Must be an HTTPS URL |") + failed = True + elif "{version}" not in ext_source_url: + rows.append("| `source_url` | ❌ | Must contain a `{version}` placeholder " + "(e.g. `.../v{version}/plugin.zip`) |") + failed = True + else: + if is_semver(version): + resolved_url = ext_source_url.replace("{version}", version) + code = http_status(resolved_url) + if code == "200": + rows.append("| Release artifact | ✅ | Artifact reachable at resolved URL |") + m = _GH_DOWNLOAD_RE.match(resolved_url) + if m: + gh_owner, gh_repo, gh_tag = m.group(1), m.group(2), m.group(3) + release_link = f"https://github.com/{gh_owner}/{gh_repo}/releases/tag/{gh_tag}" + if old_version and old_source_url_tmpl: + old_resolved = old_source_url_tmpl.replace("{version}", old_version) + om = _GH_DOWNLOAD_RE.match(old_resolved) + if om: + old_gh_tag = om.group(3) + compare_link = (f"https://github.com/{gh_owner}/{gh_repo}" + f"/compare/{old_gh_tag}...{gh_tag}") + else: + rows.append(f"| Release artifact | ❌ | Could not reach `{resolved_url}` " + f"(HTTP `{code}`) — ensure the release exists |") + failed = True + if not ext_repo_url: + rows.append("| `repo_url` | ❌ | Required for external plugins — set to the upstream " + "source repository URL |") + failed = True + + # Maintainers + if not author and not maintainers_sp: + rows.append("| Maintainers | ❌ | At least one of `author` or `maintainers` must " + "include your GitHub username |") + failed = True + else: + parts = [] + if author: + parts.append(f"`{author}`") + for m in maintainers_sp.split(): + parts.append(f"`{m}`") + rows.append(f"| Maintainers | ✅ | {', '.join(parts)} |") + + # License + license_id = jq_r(raw.get("license"), "") + if not license_id: + rows.append("| License | ❌ | `license` is required - provide an " + "[OSI-approved SPDX identifier](https://spdx.org/licenses/) " + "(e.g. `MIT`, `Apache-2.0`) |") + failed = True + else: + spdx = fetch_spdx() + if spdx is None: + rows.append("| License | ⚠️ | Could not fetch SPDX license list - skipping validation |") + else: + osi = {l["licenseId"]: l.get("name", "") + for l in spdx.get("licenses", []) if l.get("isOsiApproved") is True} + if license_id in osi: + rows.append(f"| License | ✅ | `{license_id}` - {osi[license_id]} |") + else: + rows.append(f"| License | ❌ | `{license_id}` is not an " + "[OSI-approved SPDX identifier](https://spdx.org/licenses/) |") + failed = True + + # Permission + is_repo_maintainer = bool(owner) and gh.has_write_access(owner, name, pr_author) + if is_repo_maintainer: + rows.append("| Permission | ✅ | You have permission to modify this plugin |") + has_permission = True + elif base_raw is not None: + if author_in_plugin_json(pr_author, base_raw): + rows.append("| Permission | ✅ | You have permission to modify this plugin |") + has_permission = True + else: + rows.append(f"| Permission | ❌ | `{pr_author}` is not listed in `author` or `maintainers` |") + failed = True + else: + if pr_author == author or pr_author in maintainers: + rows.append(f"| Permission | ✅ | New plugin - `{pr_author}` listed in `author`/`maintainers` |") + has_permission = True + else: + rows.append(f"| Permission | ❌ | Add `\"author\": \"{pr_author}\"` to plugin.json |") + failed = True + + # Version format + if is_semver(version): + rows.append(f"| Version | ✅ | `{version}` |") + else: + rows.append(f"| Version | ❌ | `{version}` is not valid semver - expected `X.Y.Z` |") + failed = True + + # Version bump + if base_raw is not None: + if version_greater_than(version, old_version): + rows.append(f"| Version bump | ✅ | `{old_version}` → `{version}` |") + else: + changed_fields = changed_json_fields(base_raw, raw) + metadata_only = all(f in METADATA_ONLY_FIELDS for f in changed_fields) + if metadata_only and changed_fields: + rows.append(f"| Version bump | ✅ | `{old_version}` (unchanged - metadata-only update) |") + else: + other_changed = "" + if not changed_fields: + others = git.diff_name_only_range(f"origin/{base_ref}...HEAD", [plugin_dir]) + others = [f for f in others if f != plugin_json_path] + other_changed = others[0] if others else "" + if not changed_fields and not other_changed: + rows.append("| Version bump | ❌ | No changes detected - nothing to publish |") + else: + rows.append(f"| Version bump | ❌ | `{version}` must be greater than current `{old_version}` |") + failed = True + else: + rows.append("| Version bump | ✅ | New plugin |") + is_new = True + + # Dispatcharr version constraints + min_da = jq_r(raw.get("min_dispatcharr_version"), "") + max_da = jq_r(raw.get("max_dispatcharr_version"), "") + min_ok = is_dispatcharr_version(min_da) + max_ok = is_dispatcharr_version(max_da) + if min_da and not min_ok: + rows.append(f"| `min_dispatcharr_version` | ❌ | `{min_da}` is not valid semver - " + "expected `X.Y.Z` or `vX.Y.Z` |") + failed = True + if max_da and not max_ok: + rows.append(f"| `max_dispatcharr_version` | ❌ | `{max_da}` is not valid semver - " + "expected `X.Y.Z` or `vX.Y.Z` |") + failed = True + if min_da and max_da and max_ok and min_ok: + _max = max_da[1:] if max_da.startswith("v") else max_da + _min = min_da[1:] if min_da.startswith("v") else min_da + if not version_greater_than(_max, _min) and _max != _min: + rows.append(f"| Version range | ❌ | `max_dispatcharr_version` (`{max_da}`) must be ≥ " + f"`min_dispatcharr_version` (`{min_da}`) |") + failed = True + + # Optional link fields + repo_url = jq_r(raw.get("repo_url"), "") + discord_thread = jq_r(raw.get("discord_thread"), "") + if repo_url and not re.match(r"^https?://", repo_url): + rows.append("| `repo_url` | ❌ | Must start with `http://` or `https://` |") + failed = True + if discord_thread and not re.match(r"^https?://", discord_thread): + rows.append("| `discord_thread` | ❌ | Must start with `http://` or `https://` |") + failed = True + + # Release / compare links (after the table) + if release_link: + link_line = f"[View release {gh_tag} on GitHub]({release_link})" + if compare_link: + link_line += f" · [Compare {old_gh_tag}...{gh_tag}]({compare_link})" + post_lines.append(link_line) + post_lines.append("") + + # Meta row + meta_fields = [ + jq_r(raw.get("name"), ""), + jq_r(raw.get("version"), ""), + jq_r(raw.get("description"), ""), + author, + ", ".join(maintainers), + repo_url, + discord_thread, + "fail" if failed else "pass", + ] + meta_row = f"" + + emit_fragment() + actions.set_outputs( + result="pass" if not failed else "fail", + is_new="true" if is_new else "false", + has_permission="true" if has_permission else "false", + ) + return 1 if failed else 0 + + +def _write(path: str, text: str) -> None: + if path in ("/dev/stdout", "-"): + print(text, end="") + return + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) diff --git a/.github/pluginctl/src/pluginctl/validate/report.py b/.github/pluginctl/src/pluginctl/validate/report.py new file mode 100644 index 00000000..5e91dfb5 --- /dev/null +++ b/.github/pluginctl/src/pluginctl/validate/report.py @@ -0,0 +1,504 @@ +"""``pluginctl report``: port of validate/report.sh. + +Combines per-plugin fragments, renders the single ```` +comment (same markers / sections / separators / emoji as the Bash), minimizes +prior validation comments as outdated, posts the new one, and closes the PR for +the unauthorized/blacklisted variants. The comment builder is pure so its exact +text is unit-testable; ``run`` performs the IO around it. +""" + +from __future__ import annotations + +import glob +import os +from dataclasses import dataclass, field +from typing import Optional + +from .. import capabilities +from ..core import actions, gh, git + +MARKER = "" + +# close_reason values for which the PR itself is closed (vs. merely reported on). +CLOSING_REASONS = ("unauthorized", "author-blacklisted", "plugin-blacklisted") + + +def closes_pr(close_pr: bool, close_reason: str) -> bool: + return close_pr and close_reason in CLOSING_REASONS + + +@dataclass +class ParsedFragments: + combined_body: str = "" + plugin_links: str = "" + any_failed: bool = False + pr_plugin_names: list[str] = field(default_factory=list) + + +def _read(path: str) -> str: + try: + with open(path, encoding="utf-8") as fh: + return fh.read() + except OSError: + return "" + + +def parse_fragments(fragments_dir: str) -> ParsedFragments: + result = ParsedFragments() + for fragment in sorted(glob.glob(os.path.join(fragments_dir, "*.fragment.md"))): + content = _read(fragment) + # META_ROW extraction. plugin.py always appends the genuine marker as + # the fragment's last line, so anchor here rather than taking the + # first match - a free-text field (e.g. description) earlier in the + # fragment must never be able to forge this by embedding its own + # ""): + meta = meta[:-3] + fields = meta.split("\t") + fields += [""] * (8 - len(fields)) + f_name, f_version, f_desc, f_author, f_maint, f_repo, f_discord, f_status = fields[:8] + if f_repo or f_discord: + result.plugin_links += f"**`{f_name}`:**\n" + if f_repo: + result.plugin_links += f"- [GitHub Repository]({f_repo})\n" + if f_discord: + result.plugin_links += f"- [Discord Thread]({f_discord})\n" + result.plugin_links += "\n" + # visible = fragment minus META_ROW lines, trailing whitespace stripped ($() semantics) + visible_lines = [ln for ln in content.splitlines() if not ln.startswith("\n") + assert "# Plugin Validation Results" in c + assert "## 🎉 All validation checks passed!" in c + assert "This PR modifies **1** plugin(s) and all checks have passed." in c + + +def test_fragment_failure_shows_failed(): + c = report.build_comment(**_base_kwargs(fragment_failed=True)) + assert "## ❌ Validation failed" in c + assert "🎉" not in c + + +def test_unauthorized_close_variant(): + c = report.build_comment(**_base_kwargs(close_pr=True, close_reason="unauthorized")) + assert "## PR Closed: Unauthorized" in c + assert "automatically closed" in c + # main body / success block suppressed + assert "🎉" not in c + + +def test_blacklisted_author_variant(): + c = report.build_comment(**_base_kwargs(close_pr=True, close_reason="author-blacklisted")) + assert "## PR Closed: Account Restricted" in c + + +def test_codeql_high_block_and_separator(): + c = report.build_comment(**_base_kwargs( + codeql_result="failure", codeql_errors="2", + codeql_findings="| Rule | Location | Description |\n")) + assert "❌ **CodeQL found 2 high or critical issue(s)**" in c + assert "\n---\n" in c + assert "## ❌ Validation failed" in c + + +def test_clamav_failure_block(): + c = report.build_comment(**_base_kwargs( + clamav_result="failure", clamav_infected="3", + clamav_findings="| File | Signature |\n")) + assert "❌ **ClamAV detected 3 infected file(s)**." in c + + +def test_title_invalid_block(): + c = report.build_comment(**_base_kwargs( + title_valid="false", title_feedback="Bad prefix.", + title_suggestion="[repo]: x")) + assert "### ❌ PR Title Format" in c + assert "Bad prefix." in c + assert "**Suggested format:** `[repo]: x`" in c + assert "## ❌ Validation failed" in c + + +def test_test_suite_failure_section_and_summary(): + c = report.build_comment(**_base_kwargs(test_result="failure")) + assert "### ❌ Tooling test suite" in c + assert "pluginctl" in c + assert "## ❌ Validation failed" in c + assert "🎉" not in c + + +def test_test_suite_success_renders_nothing(): + c = report.build_comment(**_base_kwargs(test_result="success")) + assert "Tooling test suite" not in c + assert "## 🎉 All validation checks passed!" in c + + +def test_test_suite_skipped_renders_nothing(): + c = report.build_comment(**_base_kwargs(test_result="skipped")) + assert "Tooling test suite" not in c + + +def test_codeql_suppressed_block_is_informational_only(): + c = report.build_comment(**_base_kwargs( + codeql_result="success", codeql_suppressed="2", + codeql_suppressed_findings="| Rule | Location | Description |\n")) + assert "**2 finding(s) suppressed via inline `codeql[...]` comment**" in c + assert "Maintainer review required." in c + assert "auto-merge" not in c + # informational only - must not flip the overall pass/fail state + assert "## 🎉 All validation checks passed!" in c + + +def test_codeql_suppressed_skipped_when_codeql_skipped(): + c = report.build_comment(**_base_kwargs( + codeql_result="skipped", codeql_suppressed="2")) + assert "suppressed via inline" not in c + + +def test_sandbox_bypass_is_informational_and_precedes_high_codeql(): + c = report.build_comment(**_base_kwargs( + codeql_result="failure", codeql_errors="1", codeql_sandbox_bypass="2", + codeql_findings="high findings", codeql_sandbox_findings="sandbox findings")) + assert "## ❌ Validation failed" in c # caused by the ordinary high finding + assert c.index("sandbox findings") < c.index("high findings") + + +def test_sandbox_bypass_alone_does_not_fail_comment(): + c = report.build_comment(**_base_kwargs( + codeql_result="success", codeql_sandbox_bypass="1", + codeql_sandbox_findings="sandbox findings")) + assert "Maintainer review required." in c + assert "## 🎉 All validation checks passed!" in c + assert "\n---\n" in c + + +def test_sandbox_bypass_is_hidden_when_codeql_is_skipped(): + c = report.build_comment(**_base_kwargs( + codeql_result="skipped", codeql_sandbox_bypass="1")) + assert "Manual Review Required" not in c + + +def test_capability_contract_is_informational_and_review_gated(): + c = report.build_comment(**_base_kwargs( + codeql_result="success", codeql_capability_contract="1", + codeql_capability_contract_findings="contract findings")) + assert "undeclared capability finding" in c + assert "Maintainer review required." in c + assert "contract findings" in c + assert "## 🎉 All validation checks passed!" in c + + +def test_codeql_skipped_notice_no_separator_when_only_success(): + c = report.build_comment(**_base_kwargs(codeql_result="success")) + # success + no unscanned -> no findings separator, still passes + assert "## 🎉 All validation checks passed!" in c + + +def test_parse_fragments(tmp_path): + f = tmp_path / "demo.fragment.md" + f.write_text("### Plugin: `demo`\n\n| ok |\n\n", + encoding="utf-8") + parsed = report.parse_fragments(str(tmp_path)) + assert parsed.pr_plugin_names == ["demo"] + assert ""-prefixed line must not be able to spoof the + Plugin Contact Links - only the genuine, always-last marker counts.""" + f = tmp_path / "demo.fragment.md" + f.write_text( + "### Plugin: `demo`\n\n" + "_Cool plugin_\n" + "\n" + "\n| ok |\n" + "\n", + encoding="utf-8", + ) + parsed = report.parse_fragments(str(tmp_path)) + assert "evil.example" not in parsed.plugin_links + assert "discord.gg/evil" not in parsed.plugin_links + assert "**`Demo`:**" in parsed.plugin_links + assert "- [GitHub Repository](https://real.example)" in parsed.plugin_links + assert parsed.any_failed is False + + +def test_parse_fragments_uses_validator_status_not_free_text(tmp_path): + f = tmp_path / "demo.fragment.md" + f.write_text( + "### Plugin: `demo`\n\n" + "_A user-controlled description containing ❌_\n" + "\n", + encoding="utf-8", + ) + assert report.parse_fragments(str(tmp_path)).any_failed is False diff --git a/.github/pluginctl/tests/test_sarif.py b/.github/pluginctl/tests/test_sarif.py new file mode 100644 index 00000000..0aabd10a --- /dev/null +++ b/.github/pluginctl/tests/test_sarif.py @@ -0,0 +1,241 @@ +import json +import os + +from pluginctl.validate import sarif + +FIX = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fixtures") + + +def _load(): + import json + with open(os.path.join(FIX, "sample.sarif"), encoding="utf-8") as fh: + return [json.load(fh)] + + +def test_extension_rules_override_driver(): + objs = _load() + secmap = sarif.build_secmap(objs[0]["runs"][0]) + # driver had 6.5, extension 6.9 -> extension wins + assert secmap["py/weak-hash"] == 6.9 + + +def test_detection_feature_flags_are_booleans(): + """Rollout switches may be enabled without invalidating the test suite.""" + assert isinstance(sarif.feature_flags.CAPABILITY_CONTRACT_DETECTION, bool) + assert isinstance(sarif.feature_flags.SANDBOX_BYPASS_DETECTION, bool) + + +def test_classify_buckets_with_detection_features_disabled(monkeypatch): + monkeypatch.setattr(sarif.feature_flags, "CAPABILITY_CONTRACT_DETECTION", False) + monkeypatch.setattr(sarif.feature_flags, "SANDBOX_BYPASS_DETECTION", False) + counts = sarif.classify(_load()) + assert counts.blocking == 1 + assert counts.medium == 1 + assert counts.low == 2 + assert counts.suppressed == 1 + assert counts.total == 4 + assert counts.warnings == 3 + + +def test_sandbox_bypass_is_ignored_when_disabled(monkeypatch): + monkeypatch.setattr(sarif.feature_flags, "SANDBOX_BYPASS_DETECTION", False) + counts = sarif.classify(_load()) + assert counts.sandbox_bypass == 0 + assert counts.sandbox_bypass_detected == 0 + assert counts.total == 4 + + +def test_sandbox_bypass_is_informational_when_enabled(monkeypatch): + monkeypatch.setattr(sarif.feature_flags, "SANDBOX_BYPASS_DETECTION", True) + counts = sarif.classify(_load()) + assert counts.blocking == 1 + assert counts.medium == 1 + assert counts.low == 2 + assert counts.suppressed == 2 + assert counts.sandbox_bypass == 1 + assert counts.sandbox_bypass_detected == 2 + assert counts.total == 4 + + +def test_missing_severity_defaults_to_low(): + counts = sarif.classify(_load()) + # py/unused-import has no security-severity -> 0 -> low bucket + assert counts.low == 2 + + +def test_process_message_strips_markdown_link_and_brackets(): + out = sarif.process_message("This depends on a [user-provided value](123).") + assert "user-provided value" in out + assert "[" not in out and "]" not in out # escaped to entities or removed + + +def test_process_message_zero_width_spaces(): + out = sarif.process_message("see https://x/#4 and www.y") + assert "​://" in out + assert "www​" in out + assert "#​4" in out + + +def test_process_message_not_truncated(): + out = sarif.process_message("a" * 200) + assert out == "a" * 200 + + +def test_process_message_dedupes_repeated_sentences(): + out = sarif.process_message("This value flows here. This value flows here. Then here.") + assert out == "This value flows here. Then here." + + +def test_process_message_no_repeats_untouched(): + out = sarif.process_message("First sentence. Second sentence.") + assert out == "First sentence. Second sentence." + + +def test_findings_table_blocking_links_internal(): + table = sarif.findings_table(_load(), sarif.is_blocking, "org/repo", "abc123", []) + assert "| Rule | Location | Description |" in table + assert "`py/sql-injection`" in table + assert "https://github.com/org/repo/blob/abc123/plugins/demo/main.py#L42" in table + + +def test_findings_table_external_prefix_plain_location(): + table = sarif.findings_table(_load(), sarif.is_blocking, "org/repo", "abc123", + ["plugins/demo/"]) + assert "https://github.com/org/repo/blob" not in table + assert "plugins/demo/main.py:42" in table + + +def test_findings_table_excludes_suppressed_results(): + table = sarif.findings_table(_load(), lambda sev: True, "org/repo", "abc123", []) + assert "Suppressed via inline codeql" not in table + + +def test_suppressed_findings_table_includes_only_suppressed(): + table = sarif.suppressed_findings_table(_load(), "org/repo", "abc123", []) + assert "Suppressed via inline codeql" in table + assert "plugins/demo/other.py:9" in table + # only the suppressed py/sql-injection result appears, not the blocking one + assert table.count("`py/sql-injection`") == 1 + + +def test_sandbox_findings_table_when_enabled(monkeypatch): + monkeypatch.setattr(sarif.feature_flags, "SANDBOX_BYPASS_DETECTION", True) + table = sarif.sandbox_findings_table(_load(), "org/repo", "abc123", []) + assert "`plugin/sandbox-bypass/ctypes-usage`" in table + assert "Suppressed sandbox finding" not in table + + +def test_generic_low_table_excludes_enabled_sandbox_findings(monkeypatch): + monkeypatch.setattr(sarif.feature_flags, "SANDBOX_BYPASS_DETECTION", True) + table = sarif.findings_table(_load(), sarif.is_low, "org/repo", "abc123", [], + exclude_sandbox_bypass=True) + assert "plugin/sandbox-bypass" not in table + + +def test_capability_contract_requires_an_enforcing_manifest(tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.json").write_text(json.dumps({ + "manifest_version": 2, + "capabilities": [], + }), encoding="utf-8") + monkeypatch.chdir(tmp_path) + result = { + "ruleId": "plugin/capability-contract/subprocess", + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/plugin.py"}, + "region": {"startLine": 1}}}], + } + assert sarif.contract_result_requires_review(result) + monkeypatch.setattr(sarif.feature_flags, "CAPABILITY_CONTRACT_DETECTION", True) + assert sarif.classify([{"runs": [{"results": [result]}]}]).capability_contract == 1 + + +def test_capability_contract_accepts_declared_or_legacy_manifests(tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + result = { + "ruleId": "plugin/capability-contract/subprocess", + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/plugin.py"}, + "region": {"startLine": 1}}}], + } + monkeypatch.chdir(tmp_path) + (plugin_dir / "plugin.json").write_text(json.dumps({ + "manifest_version": 2, + "capabilities": ["subprocess"], + }), encoding="utf-8") + assert not sarif.contract_result_requires_review(result) + (plugin_dir / "plugin.json").write_text(json.dumps({"manifest_version": 1}), encoding="utf-8") + assert not sarif.contract_result_requires_review(result) + + +def test_local_plugin_cannot_override_its_manifest_with_an_external_sidecar(tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + result = { + "ruleId": "plugin/capability-contract/subprocess", + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/plugin.py"}, + "region": {"startLine": 1}}}], + } + (plugin_dir / "plugin.json").write_text(json.dumps({ + "manifest_version": 2, + "capabilities": [], + }), encoding="utf-8") + (plugin_dir / ".dispatcharr-runtime-manifest.json").write_text( + json.dumps({"manifest_version": 1}), encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + assert sarif.contract_result_requires_review(result) + + +def test_external_plugin_uses_the_release_manifest_sidecar(tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + result = { + "ruleId": "plugin/capability-contract/subprocess", + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/plugin.py"}, + "region": {"startLine": 1}}}], + } + (plugin_dir / "plugin.json").write_text(json.dumps({"source_type": "external"}), encoding="utf-8") + (plugin_dir / ".dispatcharr-runtime-manifest.json").write_text(json.dumps({ + "manifest_version": 2, + "capabilities": [], + }), encoding="utf-8") + monkeypatch.chdir(tmp_path) + assert sarif.contract_result_requires_review(result) + + +def test_capability_contract_accepts_background_tasks_implied_by_async_action(tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.json").write_text(json.dumps({ + "manifest_version": 2, + "actions": [{"id": "refresh", "async": True}], + }), encoding="utf-8") + result = { + "ruleId": "plugin/capability-contract/background-tasks", + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/plugin.py"}, + "region": {"startLine": 1}}}], + } + monkeypatch.chdir(tmp_path) + assert not sarif.contract_result_requires_review(result) + + +def test_capability_contract_reviews_explicit_malformed_versions(tmp_path, monkeypatch): + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.json").write_text(json.dumps({"manifest_version": "two"}), encoding="utf-8") + result = { + "ruleId": "plugin/capability-contract/subprocess", + "locations": [{"physicalLocation": {"artifactLocation": {"uri": "plugins/demo/plugin.py"}, + "region": {"startLine": 1}}}], + } + monkeypatch.chdir(tmp_path) + assert sarif.contract_result_requires_review(result) + + +def test_compute_status(): + assert sarif.compute_status(1, False, True, False) == "failure" + assert sarif.compute_status(0, True, True, False) == "failure" + assert sarif.compute_status(0, False, False, False) == "skipped" + assert sarif.compute_status(0, False, True, True) == "skipped" + assert sarif.compute_status(0, False, True, False) == "success" diff --git a/.github/pluginctl/tests/test_title.py b/.github/pluginctl/tests/test_title.py new file mode 100644 index 00000000..eafda0e0 --- /dev/null +++ b/.github/pluginctl/tests/test_title.py @@ -0,0 +1,48 @@ +from pluginctl.validate.title import check_title + + +def test_repo_prefix_zero_plugins_ok(): + r = check_title("[repo]: fix workflow", "alice", 0, []) + assert r.valid and r.feedback == "" + + +def test_repo_prefix_zero_plugins_wrong(): + r = check_title("[alice]: fix workflow", "alice", 0, []) + assert not r.valid + assert "should be `[repo]`" in r.feedback + assert r.suggestion == "[repo]: Brief description of changes" + + +def test_single_plugin_prefix_ok(): + r = check_title("[my-plugin]: bump to 1.2.0", "bob", 1, ["my-plugin"]) + assert r.valid + + +def test_single_plugin_prefix_mismatch(): + r = check_title("[wrong]: bump", "bob", 1, ["my-plugin"]) + assert not r.valid + assert "`[my-plugin]`" in r.feedback + assert r.suggestion == "[my-plugin]: Brief description of changes" + + +def test_multi_plugin_requires_author_prefix(): + r = check_title("[carol]: update several", "carol", 3, ["a", "b", "c"]) + assert r.valid + + +def test_multi_plugin_wrong_prefix(): + r = check_title("[a]: update several", "carol", 3, ["a", "b", "c"]) + assert not r.valid + assert "`[carol]`" in r.feedback + assert r.suggestion == "[carol]: Brief description of changes" + + +def test_malformed_title(): + r = check_title("no brackets here", "dan", 1, ["p"]) + assert not r.valid + assert "does not match the required format" in r.feedback + + +def test_optional_colon_and_whitespace(): + assert check_title("[repo] just a space no colon", "e", 0, []).valid + assert check_title("[repo]:with-no-space", "e", 0, []).valid is False diff --git a/.github/pluginctl/tests/test_validate.py b/.github/pluginctl/tests/test_validate.py new file mode 100644 index 00000000..e714d45e --- /dev/null +++ b/.github/pluginctl/tests/test_validate.py @@ -0,0 +1,157 @@ +import json +import os + +import pytest + +from pluginctl.validate import plugin as validate +from pluginctl.core import gh, git + + +@pytest.fixture +def plugin_repo(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + pdir = tmp_path / "plugins" / "demo-plugin" + pdir.mkdir(parents=True) + (pdir / "plugin.json").write_text(json.dumps({ + "name": "Demo Plugin", + "version": "1.0.0", + "description": "A demo plugin", + "author": "alice", + "license": "MIT", + "repo_url": "https://github.com/x/demo", + }), encoding="utf-8") + (pdir / "main.py").write_text("print('hi')\n", encoding="utf-8") + # Capture GITHUB_OUTPUT + out = tmp_path / "gh_output" + out.write_text("", encoding="utf-8") + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + # Stub network + gh + git + monkeypatch.setattr(gh, "has_write_access", lambda *a, **k: False) + monkeypatch.setattr(git, "show", lambda *a, **k: None) + monkeypatch.setattr(validate, "fetch_spdx", lambda: { + "licenses": [{"licenseId": "MIT", "name": "MIT License", "isOsiApproved": True}] + }) + return tmp_path, out + + +def _outputs(out_path) -> dict: + d = {} + for line in out_path.read_text(encoding="utf-8").splitlines(): + if "=" in line: + k, _, v = line.partition("=") + d[k] = v + return d + + +def test_new_local_plugin_passes(plugin_repo): + tmp_path, out = plugin_repo + frag = tmp_path / "frag.md" + rc = validate.run("demo-plugin", "alice", "main", str(frag), repo="org/repo") + assert rc == 0 + outputs = _outputs(out) + assert outputs["result"] == "pass" + assert outputs["is_new"] == "true" + assert outputs["has_permission"] == "true" + + expected = ( + "### Plugin: `demo-plugin`\n" + "\n" + "_A demo plugin_\n" + "\n" + "[Source Repository](https://github.com/x/demo)\n" + "\n" + "| Check | Status | Details |\n" + "|-------|:------:|---------|\n" + "| Required fields | ✅ | All required fields present |\n" + "| Maintainers | ✅ | `alice` |\n" + "| License | ✅ | `MIT` - MIT License |\n" + "| Permission | ✅ | New plugin - `alice` listed in `author`/`maintainers` |\n" + "| Version | ✅ | `1.0.0` |\n" + "| Version bump | ✅ | New plugin |\n" + "\n" + "\n" + ) + assert frag.read_text(encoding="utf-8") == expected + + +def test_missing_plugin_json_early_exit(plugin_repo): + tmp_path, out = plugin_repo + os.remove(tmp_path / "plugins" / "demo-plugin" / "plugin.json") + frag = tmp_path / "frag.md" + rc = validate.run("demo-plugin", "alice", "main", str(frag), repo="org/repo") + assert rc == 0 + assert _outputs(out)["result"] == "fail" + text = frag.read_text(encoding="utf-8") + assert "| `plugin.json` | ❌ | File missing |" in text + assert ""-prefixed line + must not produce that line in the fragment - only the genuine, always-last + marker plugin.py itself appends should ever start with that prefix.""" + tmp_path, out = plugin_repo + pj = tmp_path / "plugins" / "demo-plugin" / "plugin.json" + data = json.loads(pj.read_text()) + data["description"] = ("Cool plugin\n") + pj.write_text(json.dumps(data), encoding="utf-8") + frag = tmp_path / "frag.md" + validate.run("demo-plugin", "alice", "main", str(frag), repo="org/repo") + lines = frag.read_text(encoding="utf-8").splitlines() + meta_lines = [ln for ln in lines if ln.startswith("")].split("\t") + f_repo, f_discord = fields[5], fields[6] + assert "evil.example" not in f_repo + assert "discord.gg/evil" not in f_discord + + +def test_ai_assisted_true_is_accepted(plugin_repo): + tmp_path, out = plugin_repo + pj = tmp_path / "plugins" / "demo-plugin" / "plugin.json" + data = json.loads(pj.read_text()) + data["ai_assisted"] = True + pj.write_text(json.dumps(data), encoding="utf-8") + + assert validate.run("demo-plugin", "alice", "main", str(tmp_path / "frag.md"), repo="org/repo") == 0 + assert _outputs(out)["result"] == "pass" + + +@pytest.mark.parametrize("value", [False, None, "true", 1]) +def test_ai_assisted_non_positive_values_are_ignored(plugin_repo, value): + tmp_path, out = plugin_repo + pj = tmp_path / "plugins" / "demo-plugin" / "plugin.json" + data = json.loads(pj.read_text()) + data["ai_assisted"] = value + pj.write_text(json.dumps(data), encoding="utf-8") + + fragment = tmp_path / "frag.md" + assert validate.run("demo-plugin", "alice", "main", str(fragment), repo="org/repo") == 0 + assert _outputs(out)["result"] == "pass" + assert "ai_assisted" not in fragment.read_text(encoding="utf-8") diff --git a/.github/pluginctl/tests/test_version.py b/.github/pluginctl/tests/test_version.py new file mode 100644 index 00000000..126c1dcc --- /dev/null +++ b/.github/pluginctl/tests/test_version.py @@ -0,0 +1,39 @@ +import pytest + +from pluginctl.core import version as v + + +@pytest.mark.parametrize("s,ok", [ + ("1.0.0", True), ("10.20.30", True), ("0.0.1", True), + ("v1.0.0", False), ("1.0", False), ("1.0.0-1", False), ("1.0.0a", False), ("", False), +]) +def test_is_semver(s, ok): + assert v.is_semver(s) is ok + + +@pytest.mark.parametrize("s,ok", [ + ("1.0.0", True), ("v1.0.0", True), ("v10.2.3", True), + ("1.0", False), ("x1.0.0", False), ("", False), +]) +def test_is_dispatcharr_version(s, ok): + assert v.is_dispatcharr_version(s) is ok + + +@pytest.mark.parametrize("new,old,gt", [ + ("1.0.1", "1.0.0", True), + ("1.1.0", "1.0.9", True), + ("2.0.0", "1.9.9", True), + ("1.0.0", "1.0.0", False), # equal is not greater + ("1.0.0", "1.0.1", False), + ("0.9.9", "1.0.0", False), + ("0.2.08", "0.2.07", True), # zero-padded segment (upstream Plugins#193 regression) + ("1.09.0", "1.08.0", True), # zero-padded minor segment + ("1.0.08", "1.0.8", False), # equal numeric value across padding, not greater +]) +def test_version_greater_than(new, old, gt): + assert v.version_greater_than(new, old) is gt + + +def test_sort_versions_desc(): + assert v.sort_versions_desc(["1.0.0", "1.10.0", "1.2.0", "1.0.0-1"]) == \ + ["1.10.0", "1.2.0", "1.0.0-1", "1.0.0"] diff --git a/.github/pluginctl/tests/test_webhooks.py b/.github/pluginctl/tests/test_webhooks.py new file mode 100644 index 00000000..f44132fd --- /dev/null +++ b/.github/pluginctl/tests/test_webhooks.py @@ -0,0 +1,91 @@ +import hashlib +import hmac +import json + +from pluginctl.integrations import webhooks + + +def test_sign_matches_manual_hmac(): + body = b'{"a":1}' + expected = "sha256=" + hmac.new(b"secret", body, hashlib.sha256).hexdigest() + assert webhooks.sign("secret", body) == expected + + +def test_envelope_shape(): + env = webhooks.build_envelope("plugin.published", {"plugin": "p"}, "org/repo", "alice") + assert env["event"] == "plugin.published" + assert env["repository"] == "org/repo" + assert env["actor"] == "alice" + assert env["data"] == {"plugin": "p"} + assert env["delivered_at"].endswith("Z") + + +def test_emit_noop_when_unconfigured(): + assert webhooks.emit("x", {}, url="", secret="") is False + assert webhooks.emit("x", {}, url="https://h", secret="") is False + + +def test_emit_delivers_with_valid_signature(): + captured = {} + + class _Resp: + status = 200 + def __enter__(self): return self + def __exit__(self, *a): return False + def getcode(self): return 200 + + def fake_opener(request, timeout=15): + captured["url"] = request.full_url + captured["headers"] = {k.lower(): v for k, v in request.header_items()} + captured["body"] = request.data + return _Resp() + + ok = webhooks.emit("plugin.published", {"plugin": "p", "version": "1.0.0"}, + repository="org/repo", actor="bot", + url="https://hook.example", secret="topsecret", + _opener=fake_opener) + assert ok is True + sig = captured["headers"]["x-pluginctl-signature"] + assert sig == webhooks.sign("topsecret", captured["body"]) + assert captured["headers"]["x-pluginctl-event"] == "plugin.published" + assert "x-pluginctl-delivery" in captured["headers"] + body = json.loads(captured["body"]) + assert body["data"]["version"] == "1.0.0" + + +def test_emit_swallows_delivery_error(): + def boom(request, timeout=15): + raise OSError("connection refused") + assert webhooks.emit("x", {}, url="https://h", secret="s", _opener=boom) is False + + +def test_discord_payload_uses_components_v2_and_optional_logo(): + payload = webhooks.discord_payload("plugin.published", { + "plugin": "demo", "version": "1.0.0", "logo_url": "https://images.example/demo.png", + }, "owner/repo") + assert payload["flags"] == 32768 + assert "content" not in payload + container = payload["components"][0] + assert container["type"] == 17 + section = container["components"][2] + assert section["type"] == 9 + assert section["accessory"]["type"] == 11 + + +def test_discord_routes_and_wildcard_delivery(): + captured = [] + class _Resp: + status = 204 + def __enter__(self): return self + def __exit__(self, *args): return False + def getcode(self): return 204 + def opener(request, timeout=15): + captured.append(request.full_url) + return _Resp() + config = json.dumps({ + "published": {"url": "https://discord.example/a", "events": ["plugin.published"]}, + "all": {"url": "https://discord.example/b?wait=true", "events": ["*"]}, + }) + assert webhooks.emit_discord("plugin.published", {"plugin": "demo"}, config=config, _opener=opener) + assert len(captured) == 2 + assert all("with_components=true" in url for url in captured) diff --git a/.github/pluginctl/tests/test_zips_safe_name.py b/.github/pluginctl/tests/test_zips_safe_name.py new file mode 100644 index 00000000..fc55ea93 --- /dev/null +++ b/.github/pluginctl/tests/test_zips_safe_name.py @@ -0,0 +1,31 @@ +import json + +from pluginctl.core import gh +from pluginctl.publish import zips + + +def test_run_skips_unsafe_named_plugin_directory(tmp_path, monkeypatch): + """publish/zips.run() must never build/upload a plugin folder whose name + failed the validate-time safe-name allowlist, even if it somehow reached + `main` (defense in depth alongside the detect.py gate).""" + monkeypatch.chdir(tmp_path) + + safe_dir = tmp_path / "plugins" / "good-plugin" + safe_dir.mkdir(parents=True) + (safe_dir / "plugin.json").write_text(json.dumps({ + "name": "Good Plugin", "version": "1.0.0", + }), encoding="utf-8") + + # Unsafe-named folder with no plugin.json - if run() ever tried to process + # it (instead of skipping via the safe-name check), this would raise + # FileNotFoundError and fail the test. + unsafe_dir = tmp_path / "plugins" / "Bad_Plugin" + unsafe_dir.mkdir(parents=True) + + monkeypatch.setattr(gh, "release_exists", lambda *a, **k: True) + + rc = zips.run("main", "org/repo", str(tmp_path / "build_meta")) + + assert rc == 0 + changed = (tmp_path / "changed_plugins.txt").read_text(encoding="utf-8") + assert "Bad_Plugin" not in changed diff --git a/.github/scripts/publish/build-zips.sh b/.github/scripts/publish/build-zips.sh deleted file mode 100644 index 89f0350d..00000000 --- a/.github/scripts/publish/build-zips.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/bash -set -e - -# publish-build-zips.sh -# 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 GitHub Release tag. -# Uploads each new ZIP to GitHub Releases (versioned tag + -latest alias tag). -# Writes changed_plugins.txt to cwd (one "name@version" per line). -# -# Called from the releases branch checkout directory by publish-plugins.sh. -# Required env: SOURCE_BRANCH, RELEASES_BRANCH, GITHUB_REPOSITORY, GITHUB_TOKEN - -: "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" "${BUILD_META_DIR:?}" "${GITHUB_TOKEN:?}" - -> changed_plugins.txt - -for plugin_dir in plugins/*/; do - [[ ! -d "$plugin_dir" ]] && continue - plugin_name=$(basename "$plugin_dir") - plugin_key=${plugin_name//-/_} - version=$(jq -r '.version' "$plugin_dir/plugin.json") - - mkdir -p "metadata/$plugin_name" - - zip_path="/tmp/${plugin_name}-${version}.zip" - existing_manifest="metadata/$plugin_name/manifest.json" - release_tag="${plugin_name}-${version}" - - # Skip if a GitHub Release already exists for this version. - # The release is the source of truth; the manifest is regenerated from it. - if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo " $plugin_name v$version - skipping (release already exists)" - continue - fi - - source_type=$(jq -r '.source_type // "local"' "$plugin_dir/plugin.json") - build_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - if [[ "$source_type" == "external" ]]; then - source_url_template=$(jq -r '.source_url' "$plugin_dir/plugin.json") - source_url_resolved="${source_url_template//\{version\}/$version}" - echo " $plugin_name v$version - fetching external ZIP from $source_url_resolved" - echo "$plugin_key@$version" >> changed_plugins.txt - download_ok=false - for attempt in 1 2 3; do - if curl -fsSL "$source_url_resolved" -o "$zip_path"; then - download_ok=true - break - fi - rm -f "$zip_path" - if [[ "$attempt" -lt 3 ]]; then - echo " Download attempt $attempt failed, retrying in 15s..." - sleep 15 - fi - done - if [[ "$download_ok" != "true" ]]; then - echo "::error::Failed to download external ZIP from $source_url_resolved after 3 attempts" - exit 1 - fi - commit_sha=$(git log -1 --format=%H origin/$SOURCE_BRANCH -- "$plugin_dir") - commit_sha_short=$(git log -1 --format=%h origin/$SOURCE_BRANCH -- "$plugin_dir") - last_updated=$(git log -1 --format=%cI origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null \ - || date -u +"%Y-%m-%dT%H:%M:%SZ") - else - echo " $plugin_name v$version - building" - echo "$plugin_key@$version" >> changed_plugins.txt - commit_sha=$(git log -1 --format=%H origin/$SOURCE_BRANCH -- "$plugin_dir") - commit_sha_short=$(git log -1 --format=%h origin/$SOURCE_BRANCH -- "$plugin_dir") - last_updated=$(git log -1 --format=%cI origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null \ - || date -u +"%Y-%m-%dT%H:%M:%SZ") - source_url_resolved="" - ( - tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT - cp -r "plugins/$plugin_name" "$tmpdir/$plugin_key" - cd "$tmpdir" && zip -r "$zip_path" "$plugin_key" -q - ) - fi - - checksum_md5=$(md5sum "$zip_path" | awk '{print $1}') - checksum_sha256=$(shasum -a 256 "$zip_path" | awk '{print $1}') - - min_da_version=$(jq -r '.min_dispatcharr_version // ""' "$plugin_dir/plugin.json") - max_da_version=$(jq -r '.max_dispatcharr_version // ""' "$plugin_dir/plugin.json") - - zip_size_bytes=$(stat -f%z "$zip_path" 2>/dev/null || stat -c%s "$zip_path" 2>/dev/null || echo 0) - zip_size_kb=$(( zip_size_bytes / 1024 )) - - mkdir -p "$BUILD_META_DIR/$plugin_key" - jq -n \ - --arg version "$version" \ - --arg commit_sha "$commit_sha" \ - --arg commit_sha_short "$commit_sha_short" \ - --arg build_timestamp "$build_timestamp" \ - --arg last_updated "$last_updated" \ - --arg checksum_md5 "$checksum_md5" \ - --arg checksum_sha256 "$checksum_sha256" \ - --arg min_da_version "$min_da_version" \ - --arg max_da_version "$max_da_version" \ - --arg source_url "$source_url_resolved" \ - --argjson size_kb "$zip_size_kb" \ - '{ - version: $version, - commit_sha: (if $commit_sha != "" then $commit_sha else null end), - commit_sha_short: (if $commit_sha_short != "" then $commit_sha_short else null end), - build_timestamp: $build_timestamp, - last_updated: $last_updated, - checksum_md5: $checksum_md5, - checksum_sha256: $checksum_sha256, - min_dispatcharr_version: (if $min_da_version != "" then $min_da_version else null end), - max_dispatcharr_version: (if $max_da_version != "" then $max_da_version else null end), - source_url: (if $source_url != "" then $source_url else null end), - size_kb: $size_kb - } | with_entries(select(.value != null))' \ - > "$BUILD_META_DIR/$plugin_key/${plugin_key}-${version}.json" - - # Build release notes - readme_url="https://github.com/${GITHUB_REPOSITORY}/blob/releases/metadata/${plugin_name}/README.md" - release_notes="" - if [[ -n "$commit_sha" ]]; then - commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha}" - release_notes="**Commit:** [\`${commit_sha_short}\`](${commit_url})" - pr_info=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${commit_sha}/pulls" \ - --jq '.[0] | {number: .number, url: .html_url}' 2>/dev/null || echo '{}') - pr_number=$(echo "$pr_info" | jq -r '.number // empty') - pr_url=$(echo "$pr_info" | jq -r '.url // empty') - if [[ -n "$pr_number" && "$pr_number" != "null" ]]; then - release_notes+=$'\n'"**PR:** [#${pr_number}](${pr_url})" - fi - release_notes+=$'\n' - fi - release_notes+="**README:** [Plugin README](${readme_url})" - - # Upload versioned GitHub Release - echo " $plugin_name v$version - uploading to GitHub Releases" - gh release create "$release_tag" \ - --repo "$GITHUB_REPOSITORY" \ - --title "${plugin_name} v${version}" \ - --notes "$release_notes" \ - "$zip_path" - - rm -f "$zip_path" -done - -changed=$(wc -l < changed_plugins.txt | tr -d ' ') -echo "Built $changed new/updated plugin(s)." diff --git a/.github/scripts/publish/cleanup.sh b/.github/scripts/publish/cleanup.sh deleted file mode 100644 index b9fbc687..00000000 --- a/.github/scripts/publish/cleanup.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -set -e - -# publish-cleanup.sh -# Removes GitHub Releases for plugins that no longer exist in source, -# and prunes versioned releases beyond MAX_VERSIONED_ZIPS. -# -# Called from the releases branch checkout directory by publish-plugins.sh. -# Required env: SOURCE_BRANCH, GITHUB_REPOSITORY, GITHUB_TOKEN -# Optional env: MAX_VERSIONED_ZIPS (default: 10) - -: "${SOURCE_BRANCH:?}" "${GITHUB_REPOSITORY:?}" "${GITHUB_TOKEN:?}" -MAX_VERSIONED_ZIPS=${MAX_VERSIONED_ZIPS:-10} - -# Fetch all release tags once to avoid repeated API calls -all_tags=$(gh release list --repo "$GITHUB_REPOSITORY" --json tagName --limit 500 \ - | jq -r '.[].tagName') - -# Remove releases for deleted plugins -if [[ -d zips ]]; then - for release_dir in metadata/*/; do - [[ ! -d "$release_dir" ]] && continue - plugin_name=$(basename "$release_dir") - if [[ ! -d "plugins/$plugin_name" ]]; then - echo " Removing deleted plugin releases: $plugin_name" - echo "$all_tags" | grep "^${plugin_name}-" | while IFS= read -r tag; do - echo " Deleting release $tag" - gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag 2>/dev/null || true - done - rm -rf "$release_dir" - fi - done -fi - -# Prune old versioned releases per plugin (keep MAX_VERSIONED_ZIPS most recent) -for plugin_dir in plugins/*/; do - [[ ! -d "$plugin_dir" ]] && continue - plugin_name=$(basename "$plugin_dir") - - # Get versioned tags for this plugin (exclude -latest), sorted newest-first by semver - versioned_tags=$(echo "$all_tags" \ - | grep "^${plugin_name}-" \ - | grep -v "^${plugin_name}-latest$" \ - | sed "s/^${plugin_name}-//" \ - | sort -V -r \ - | sed "s/^/${plugin_name}-/") - - tag_count=$(echo "$versioned_tags" | grep -c . || true) - if (( tag_count <= MAX_VERSIONED_ZIPS )); then - continue - fi - - # Delete tags beyond the limit - echo "$versioned_tags" | awk "NR>$MAX_VERSIONED_ZIPS" | while IFS= read -r old_tag; do - echo " Removed release $old_tag (over limit of $MAX_VERSIONED_ZIPS)" - gh release delete "$old_tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag 2>/dev/null || true - done -done diff --git a/.github/scripts/publish/generate-manifest.sh b/.github/scripts/publish/generate-manifest.sh deleted file mode 100644 index 01738fe5..00000000 --- a/.github/scripts/publish/generate-manifest.sh +++ /dev/null @@ -1,355 +0,0 @@ -#!/bin/bash -set -e - -# publish-generate-manifest.sh -# Generates metadata//manifest.json for each plugin and the root manifest.json. -# -# Called from the releases branch checkout directory by publish-plugins.sh. -# Required env: SOURCE_BRANCH, RELEASES_BRANCH, GITHUB_REPOSITORY - -: "${SOURCE_BRANCH:?}" "${RELEASES_BRANCH:?}" "${GITHUB_REPOSITORY:?}" "${GITHUB_TOKEN:?}" - -generated_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" -registry_url="https://github.com/${GITHUB_REPOSITORY}" -registry_name="${GITHUB_REPOSITORY}" -root_url="https://github.com/${GITHUB_REPOSITORY}/releases/download" -raw_releases_url="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${RELEASES_BRANCH}" - -# GPG signing setup - optional; set GPG_PRIVATE_KEY (armored) and optionally GPG_PASSPHRASE -gpg_key_id="" -gpg_signing_failed=0 -if [[ -n "${GPG_PRIVATE_KEY:-}" ]]; then - echo "$GPG_PRIVATE_KEY" | gpg --batch --import 2>/dev/null - gpg_key_id=$(gpg --list-secret-keys --keyid-format LONG 2>/dev/null \ - | awk '/^sec/{print $2}' | head -1 | cut -d'/' -f2) - if [[ -n "$gpg_key_id" ]]; then - echo "GPG signing enabled (key: $gpg_key_id)" - else - echo "::warning::GPG key import succeeded but no usable secret key found - signatures will be skipped." - gpg_signing_failed=1 - fi -else - echo "GPG_PRIVATE_KEY not set - signatures will be skipped." -fi - -# Writes a manifest wrapper to $1 with $2 as the signed payload (.manifest), -# only when .manifest content differs from what is on disk. -# Wrapper structure: {generated_at, manifest: } -# The .signature field is added separately by sign_manifest. -# Returns 0 if written, 1 if skipped (content unchanged). -write_manifest_if_changed() { - local dest="$1" manifest_payload="$2" - local new_compact - new_compact=$(echo "$manifest_payload" | jq -c '.') - if [[ -f "$dest" ]]; then - local existing_manifest - existing_manifest=$(jq -c '.manifest' "$dest" 2>/dev/null) - if [[ "$existing_manifest" == "$new_compact" ]]; then - return 1 - fi - fi - jq -n \ - --arg generated_at "$generated_at" \ - --argjson manifest "$new_compact" \ - '{generated_at: $generated_at, manifest: $manifest}' \ - > "$dest" - return 0 -} - -# Returns 0 if the manifest at $1 has an embedded .signature made by the current -# gpg_key_id, 1 otherwise. Uses --list-packets on a temp file to read the issuer -# key ID without cryptographic verification, avoiding trust-level pitfalls. -sig_is_current() { - local file="$1" - local sig - sig=$(jq -r '.signature // empty' "$file" 2>/dev/null) - [[ -n "$sig" ]] || return 1 - local tmp_sig sig_key_id - tmp_sig=$(mktemp) - printf '%s\n' "$sig" > "$tmp_sig" - sig_key_id=$(gpg --list-packets "$tmp_sig" 2>/dev/null \ - | sed -n 's/.*issuer key ID \([A-Fa-f0-9]\{16\}\).*/\1/p' | head -1 \ - | tr 'a-f' 'A-F') - rm -f "$tmp_sig" - local cur_key_upper - cur_key_upper=$(echo "$gpg_key_id" | tr 'a-f' 'A-F') - [[ -n "$sig_key_id" && "$sig_key_id" == "$cur_key_upper" ]] -} - -# Signs the .manifest payload of $1 and embeds the armored signature as .signature -# in the same JSON file. Sets gpg_signing_failed=1 on any error. -sign_manifest() { - local file="$1" - [[ -z "$gpg_key_id" ]] && return 0 - local gpg_opts=(--batch --yes --armor --detach-sign --local-user "$gpg_key_id" --output -) - if [[ -n "${GPG_PASSPHRASE:-}" ]]; then - gpg_opts+=(--passphrase "$GPG_PASSPHRASE" --pinentry-mode loopback) - fi - local sig - sig=$(jq -ca '.manifest' "$file" | gpg "${gpg_opts[@]}" 2>/dev/null) || true - if [[ -z "$sig" ]]; then - echo "::warning::GPG signing failed for ${file} - all signatures will be removed." - gpg_signing_failed=1 - return 1 - fi - local tmp - tmp=$(mktemp) - if jq --arg sig "$sig" '. + {signature: $sig}' "$file" > "$tmp"; then - mv "$tmp" "$file" - else - rm -f "$tmp" - echo "::warning::Failed to embed signature in ${file}." - gpg_signing_failed=1 - fi -} - -plugin_entries=() -root_entries=() - -# Fetch all release tags once to avoid per-plugin API calls -echo " Fetching release tags..." -all_release_tags=$(gh release list --repo "$GITHUB_REPOSITORY" --json tagName --limit 500 \ - | jq -r '.[].tagName') - -for plugin_dir in plugins/*/; do - plugin_file="$plugin_dir/plugin.json" - [[ ! -f "$plugin_file" ]] && continue - plugin_name=$(basename "$plugin_dir") - plugin_key=${plugin_name//-/_} - current_version=$(jq -r '.version' "$plugin_file") - min_da_version=$(jq -r '.min_dispatcharr_version // ""' "$plugin_file") - max_da_version=$(jq -r '.max_dispatcharr_version // ""' "$plugin_file") - unlisted=false - [[ "$(jq -r '.unlisted // false' "$plugin_file")" == "true" ]] && unlisted=true - - echo " $plugin_name" - - versioned_zips="[]" - latest_metadata="{}" - latest_zip_version="" - latest_size_kb=0 - latest_size_set=false - - # existing per-plugin manifest from previous run - used as metadata fallback - existing_manifest_file="metadata/$plugin_name/manifest.json" - mkdir -p "metadata/$plugin_name" - - # Discover published versions from GitHub Releases (newest first) - versioned_tags=$(echo "$all_release_tags" \ - | grep "^${plugin_name}-" \ - | grep -v "^${plugin_name}-latest$" \ - | sed "s/^${plugin_name}-//" \ - | sort -V -r \ - | sed "s/^/${plugin_name}-/") - - while IFS= read -r release_tag; do - [[ -z "$release_tag" ]] && continue - zip_version="${release_tag#${plugin_name}-}" - zip_url="${plugin_name}-${zip_version}/${plugin_name}-${zip_version}.zip" - # Strip a plain-integer migration retry suffix (e.g. 1.0.0-1 -> 1.0.0) so the - # canonical version is used for metadata lookup and manifest entries. - canonical_version=$(sed 's/-[0-9][0-9]*$//' <<< "$zip_version") - - # Fresh metadata from this run takes priority; fall back to existing manifest - fresh_meta_file="${BUILD_META_DIR:-}/$plugin_key/${plugin_key}-${canonical_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 "$canonical_version" \ - '.manifest.versions[]? | select(.version == $v)' "$existing_manifest_file" 2>/dev/null || true) - [[ -n "$meta_from_manifest" ]] && metadata="$meta_from_manifest" - fi - - # Size: prefer fresh metadata (stored by build-zips.sh), fall back to existing manifest - zip_size_kb=0 - if [[ "$metadata" != "{}" ]]; then - zip_size_kb=$(echo "$metadata" | jq -r '.size_kb // .size // 0') - fi - if [[ "$latest_size_set" == "false" ]]; then - latest_size_kb=$zip_size_kb - latest_size_set=true - fi - - if [[ "$metadata" != "{}" ]]; then - versioned_zips=$(jq --arg url "$zip_url" --argjson metadata "$metadata" --argjson size "$zip_size_kb" \ - '. + [{ - version: $metadata.version, - commit_sha: $metadata.commit_sha, - commit_sha_short: $metadata.commit_sha_short, - build_timestamp: $metadata.build_timestamp, - last_updated: $metadata.last_updated, - checksum_md5: $metadata.checksum_md5, - checksum_sha256: $metadata.checksum_sha256, - min_dispatcharr_version: $metadata.min_dispatcharr_version, - max_dispatcharr_version: $metadata.max_dispatcharr_version, - source_url: $metadata.source_url, - url: $url, - size: $size - } | with_entries(select(.value != null))]' <<< "$versioned_zips") - if [[ "$latest_metadata" == "{}" ]]; then - latest_metadata="$metadata" - latest_zip_version="$zip_version" - fi - else - versioned_zips=$(jq --arg version "$canonical_version" --arg url "$zip_url" --argjson size "$zip_size_kb" \ - '. + [{version: $version, url: $url, size: $size}]' <<< "$versioned_zips") - fi - done <<< "$versioned_tags" - - # Derive latest_url from the newest versioned release (sorted newest-first above). - # Use the actual tag-derived zip_version (which may include a retry suffix) for the URL, - # so the URL resolves to the real release asset. - latest_url="" - [[ -n "$latest_zip_version" ]] && latest_url="${plugin_name}-${latest_zip_version}/${plugin_name}-${latest_zip_version}.zip" - - # Overwrite min/max_dispatcharr_version for the current version's entry from plugin.json, - # so metadata-only updates (no version bump) are reflected without a rebuild. - versioned_zips=$(jq \ - --arg v "$current_version" \ - --arg min "$min_da_version" \ - --arg max "$max_da_version" \ - 'map(if .version == $v then - . + { - min_dispatcharr_version: (if $min != "" then $min else null end), - max_dispatcharr_version: (if $max != "" then $max else null end) - } | with_entries(select(.value != null)) - else . end)' <<< "$versioned_zips") - - plugin_entry=$(jq \ - --arg plugin_name "$plugin_name" \ - --arg latest_url "$latest_url" \ - --arg registry_url "$registry_url" \ - --arg registry_name "$registry_name" \ - --argjson versioned_zips "$versioned_zips" \ - --argjson latest_metadata "$latest_metadata" \ - --argjson latest_size_kb "$latest_size_kb" \ - '{ - slug: $plugin_name, - name: .name, - description: (.description // null), - author: (.author // null), - maintainers: (.maintainers // null), - license: (.license // null), - deprecated: (if .deprecated == true then true else null end), - source_type: (if .source_type == "external" then "external" else null end), - source_url: (.source_url // null), - repo_url: (.repo_url // null), - discord_thread: (.discord_thread // null), - registry_url: $registry_url, - registry_name: $registry_name, - last_updated: ($latest_metadata.last_updated // null), - latest: (if ($latest_metadata | length > 0) then { - version: $latest_metadata.version, - commit_sha: $latest_metadata.commit_sha, - commit_sha_short: $latest_metadata.commit_sha_short, - build_timestamp: $latest_metadata.build_timestamp, - last_updated: $latest_metadata.last_updated, - checksum_md5: $latest_metadata.checksum_md5, - checksum_sha256: $latest_metadata.checksum_sha256, - min_dispatcharr_version: (.min_dispatcharr_version // null), - max_dispatcharr_version: (.max_dispatcharr_version // null), - source_url: $latest_metadata.source_url, - latest_url: $latest_url, - url: $versioned_zips[0].url, - size: $latest_size_kb - } | with_entries(select(.value != null)) else null end), - versions: $versioned_zips - } | with_entries(select(.value != null))' \ - "$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" - fi - plugin_entries+=("$plugin_entry") - - # Unlisted plugins get a per-plugin manifest but are excluded from the root manifest - [[ "$unlisted" == "true" ]] && continue - - # Compact root manifest entry - desc_raw=$(jq -r '.description // ""' "$plugin_file") - if [[ ${#desc_raw} -gt 200 ]]; then - desc_trimmed="${desc_raw:0:197}..." - else - desc_trimmed="$desc_raw" - fi - - # manifest_url is absolute: per-plugin manifest stays in the releases branch (raw.githubusercontent.com) - plugin_manifest_url="${raw_releases_url}/metadata/${plugin_name}/manifest.json" - - root_entry=$(jq -n \ - --argjson latest_metadata "$latest_metadata" \ - --argjson versioned_zips "$versioned_zips" \ - --arg slug "$plugin_name" \ - --arg name "$(jq -r '.name // ""' "$plugin_file")" \ - --arg description "$desc_trimmed" \ - --arg manifest_url "$plugin_manifest_url" \ - --arg author "$(jq -r '.author // ""' "$plugin_file")" \ - --arg license "$(jq -r '.license // ""' "$plugin_file")" \ - --argjson deprecated "$(jq 'if .deprecated == true then true else null end' "$plugin_file")" \ - --argjson latest_size_kb "$latest_size_kb" \ - --arg min_da_version "$min_da_version" \ - --arg max_da_version "$max_da_version" \ - --arg latest_url "$latest_url" \ - '{ - slug: $slug, - name: $name, - description: $description, - manifest_url: $manifest_url, - author: $author, - license: (if $license != "" then $license else null end), - deprecated: $deprecated, - last_updated: ($latest_metadata.last_updated // null), - 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_size: (if $latest_size_kb > 0 then $latest_size_kb else null end), - min_dispatcharr_version: (if $min_da_version != "" then $min_da_version else null end), - max_dispatcharr_version: (if $max_da_version != "" then $max_da_version else null end) - } | with_entries(select(.value != null))') - root_entries+=("$root_entry") -done - -inner_root=$( - { - echo '{' - echo ' "registry_url": '"$(jq -n --arg u "$registry_url" '$u')"',' - echo ' "registry_name": '"$(jq -n --arg u "$registry_name" '$u')"',' - echo ' "root_url": '"$(jq -n --arg u "$root_url" '$u')"',' - echo ' "plugins": [' - first=true - for entry in "${root_entries[@]}"; do - if [[ "$first" != true ]]; then echo ","; fi - first=false - echo "$entry" | sed 's/^/ /' - done - echo "" - echo ' ]' - echo '}' - } | jq -c '.' -) -if write_manifest_if_changed "manifest.json" "$inner_root"; then - sign_manifest "manifest.json" -elif [[ -n "$gpg_key_id" && "$gpg_signing_failed" -eq 0 ]] && ! sig_is_current "manifest.json"; then - sign_manifest "manifest.json" -fi - -# If any signing step failed, or no GPG key is configured, strip embedded -# signatures from all manifests so the repo is never left in a partially-signed -# or stale-signed state (e.g. incremental runs where unchanged manifests retain -# signatures from a previous key that is no longer present). -if [[ "$gpg_signing_failed" -eq 1 ]] || [[ -z "$gpg_key_id" ]]; then - echo "::warning::Removing all manifest signatures (no GPG key configured or signing failed)." - while IFS= read -r -d '' _f; do - _tmp=$(mktemp) - jq 'del(.signature)' "$_f" > "$_tmp" && mv "$_tmp" "$_f" || rm -f "$_tmp" - done < <(find 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/plugin-readmes.sh b/.github/scripts/publish/plugin-readmes.sh deleted file mode 100644 index b8dea2e8..00000000 --- a/.github/scripts/publish/plugin-readmes.sh +++ /dev/null @@ -1,172 +0,0 @@ -#!/bin/bash -set -e - -# publish-per-plugin-readmes.sh -# Generates metadata//README.md for every plugin. -# Version/metadata discovery is driven by the per-plugin manifest.json written -# by generate-manifest.sh (which runs before this script). No local ZIPs required. -# -# 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:?}" - -# Format an ISO8601 timestamp as "Mon DD, HH:MM UTC" -fmt_date() { date -d "$1" -u +"%b %d %Y, %H:%M UTC" 2>/dev/null || echo "$1"; } - -# Encode a string for use in a shields.io badge path segment -# spaces -> _, underscores -> __, hyphens -> -- -shields_encode() { - local s="$1" - s="${s//_/__}" - s="${s//-/--}" - s="${s// /_}" - printf '%s' "$s" -} - -# Read root_url from the root manifest (set by generate-manifest.sh) -root_url=$(jq -r '.manifest.root_url // ""' "manifest.json" 2>/dev/null || echo "") - -for plugin_dir in plugins/*/; do - [[ ! -d "$plugin_dir" ]] && continue - plugin_name=$(basename "$plugin_dir") - plugin_file="$plugin_dir/plugin.json" - [[ ! -f "$plugin_file" ]] && continue - - manifest_file="metadata/$plugin_name/manifest.json" - if [[ ! -f "$manifest_file" ]]; then - echo " $plugin_name (no manifest, skipping README)" - continue - fi - - name=$(jq -r '.name' "$plugin_file") - description=$(jq -r '.description' "$plugin_file") - author=$(jq -r '.author // ""' "$plugin_file") - maintainers=$(jq -r '[.maintainers[]?] | join(", ")' "$plugin_file") - 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") - version=$(jq -r '.version' "$plugin_file") - last_updated=$(git log -1 --format=%cI origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null \ - || date -u +"%Y-%m-%dT%H:%M:%SZ") - has_readme=false - [[ -f "$plugin_dir/README.md" ]] && has_readme=true - - # Read latest metadata from manifest - latest_url_path=$(jq -r '.manifest.latest.latest_url // empty' "$manifest_file") - latest_full_url="" - [[ -n "$root_url" && -n "$latest_url_path" ]] && latest_full_url="${root_url}/${latest_url_path}" - - { - echo "[Back to All Plugins](../../README.md)" - echo "" - echo "# $name" - echo "" - echo "**Version:** \`$version\` | **Author:** $author | **Last Updated:** $(fmt_date "$last_updated")" - echo "" - echo "$description" - echo "" - # Build badge row - local_discord_link="$discord_thread" - badges="" - if [[ -n "$license" ]]; then - badges="[![License: $license](https://img.shields.io/badge/License-$(shields_encode "$license")-blue?style=flat-square)](https://spdx.org/licenses/${license}.html)" - fi - if [[ -n "$local_discord_link" ]]; then - [[ -n "$badges" ]] && badges+=" " - badges+="[![Discord](https://img.shields.io/badge/Discord-Discussion-5865F2?style=flat-square&logo=discord&logoColor=white)]($local_discord_link)" - fi - if [[ -n "$repo_url" ]]; then - [[ -n "$badges" ]] && badges+=" " - badges+="[![Repository](https://img.shields.io/badge/GitHub-Repository-181717?style=flat-square&logo=github&logoColor=white)]($repo_url)" - fi - if [[ -n "$badges" ]]; then - echo "$badges" - echo "" - fi - if [[ -n "$min_dispatcharr" || -n "$max_dispatcharr" ]]; then - compat_badges="" - if [[ -n "$min_dispatcharr" ]]; then - compat_badges="![Dispatcharr min](https://img.shields.io/badge/Dispatcharr_min-$(shields_encode "$min_dispatcharr")-brightgreen?style=flat-square)" - fi - if [[ -n "$max_dispatcharr" ]]; then - [[ -n "$compat_badges" ]] && compat_badges+=" " - compat_badges+="![Dispatcharr max](https://img.shields.io/badge/Dispatcharr_max-$(shields_encode "$max_dispatcharr")-orange?style=flat-square)" - fi - echo "$compat_badges" - echo "" - fi - echo "## Downloads" - echo "" - echo "### Latest Release" - echo "" - - if [[ -n "$latest_full_url" ]]; then - latest_build_timestamp=$(jq -r '.manifest.latest.build_timestamp // empty' "$manifest_file") - latest_commit_sha=$(jq -r '.manifest.latest.commit_sha // empty' "$manifest_file") - latest_commit_sha_short=$(jq -r '.manifest.latest.commit_sha_short // empty' "$manifest_file") - latest_md5=$(jq -r '.manifest.latest.checksum_md5 // empty' "$manifest_file") - latest_sha256=$(jq -r '.manifest.latest.checksum_sha256 // empty' "$manifest_file") - - echo "- **Download:** [\`${plugin_name}-latest.zip\`](${latest_full_url})" - [[ -n "$latest_build_timestamp" ]] && echo "- **Built:** $(fmt_date "$latest_build_timestamp")" - [[ -n "$latest_commit_sha" ]] && echo "- **Source Commit:** [\`$latest_commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${latest_commit_sha})" - if [[ -n "$latest_md5" || -n "$latest_sha256" ]]; then - echo "" - echo "**Checksums:**" - echo "\`\`\`" - [[ -n "$latest_md5" ]] && echo "MD5: $latest_md5" - [[ -n "$latest_sha256" ]] && echo "SHA256: $latest_sha256" - echo "\`\`\`" - fi - fi - - echo "" - echo "### All Versions" - echo "" - echo "| Version | Download | Built | Commit | MD5 | SHA256 |" - echo "|---------|----------|-------|--------|-----|--------|" - - while IFS= read -r version_json; do - ver=$(echo "$version_json" | jq -r '.version // empty') - [[ -z "$ver" ]] && continue - url_path=$(echo "$version_json" | jq -r '.url // empty') - full_url="" - [[ -n "$root_url" && -n "$url_path" ]] && full_url="${root_url}/${url_path}" - commit_sha=$(echo "$version_json" | jq -r '.commit_sha // empty') - commit_sha_short=$(echo "$version_json" | jq -r '.commit_sha_short // empty') - build_timestamp=$(echo "$version_json" | jq -r '.build_timestamp // empty') - checksum_md5=$(echo "$version_json" | jq -r '.checksum_md5 // empty') - checksum_sha256=$(echo "$version_json" | jq -r '.checksum_sha256 // empty') - build_date=$(fmt_date "$build_timestamp") - commit_cell="-" - [[ -n "$commit_sha" ]] && commit_cell="[\`$commit_sha_short\`](https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha})" - download_cell="-" - [[ -n "$full_url" ]] && download_cell="[Download](${full_url})" - echo "| \`$ver\` | $download_cell | ${build_date:--} | $commit_cell | ${checksum_md5:--} | ${checksum_sha256:--} |" - done < <(jq -c '.manifest.versions[]?' "$manifest_file" 2>/dev/null) - - echo "" - echo "---" - echo "" - local_footer="" - [[ -n "$maintainers" ]] && local_footer="**Maintainers:** $maintainers | " - local_footer+="**Source:** [Browse Plugin](https://github.com/${GITHUB_REPOSITORY}/tree/$SOURCE_BRANCH/plugins/${plugin_name})" - echo "$local_footer" - echo "" - echo "**Metadata:** [View full manifest](./manifest.json)" - - if [[ "$has_readme" == "true" ]]; then - echo "" - echo "---" - echo "" - echo "## Plugin README" - echo "" - cat "$plugin_dir/README.md" - fi - } > "metadata/$plugin_name/README.md" - - echo " $plugin_name" -done diff --git a/.github/scripts/publish/releases-readme.sh b/.github/scripts/publish/releases-readme.sh deleted file mode 100644 index 0219324f..00000000 --- a/.github/scripts/publish/releases-readme.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/bin/bash -set -e - -# publish-releases-readme.sh -# Generates the root README.md for the releases branch. -# -# 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:?}" - -# Format an ISO8601 timestamp as "Mon DD, HH:MM UTC" -fmt_date() { date -d "$1" -u +"%b %d %Y, %H:%M UTC" 2>/dev/null || echo "$1"; } - -# Encode a string for use in a shields.io badge path segment -# spaces -> _, underscores -> __, hyphens -> -- -shields_encode() { - local s="$1" - s="${s//_/__}" - s="${s//-/--}" - s="${s// /_}" - printf '%s' "$s" -} - -# Render a full plugin block (used in second pass) -render_plugin() { - local is_deprecated=$1 - local plugin_name=$2 - local name=$3 - local version=$4 - local author=$5 - local description=$6 - local maintainers=$7 - local last_updated=$8 - local commit_sha=$9 - local commit_sha_short=${10} - local version_count=${11} - local license=${12} - local min_dispatcharr=${13} - local max_dispatcharr=${14} - local repo_url=${15} - local discord_thread=${16} - - local manifest_file="./metadata/${plugin_name}/manifest.json" - local root_url - root_url=$(jq -r '.manifest.root_url // ""' "manifest.json" 2>/dev/null || echo "") - local latest_url_path="" - [[ -f "$manifest_file" ]] && latest_url_path=$(jq -r '.manifest.latest.latest_url // empty' "$manifest_file") - local zip_url="" - [[ -n "$root_url" && -n "$latest_url_path" ]] && zip_url="${root_url}/${latest_url_path}" - 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/metadata/${plugin_name}/README.md" - local commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${commit_sha}" - local releases_dir="./metadata/${plugin_name}" - local has_source_readme=false - [[ -f "plugins/$plugin_name/README.md" ]] && has_source_readme=true - - local suffix="" - [[ "$is_deprecated" == "true" ]] && suffix=" (deprecated)" - - echo "### [$name]($releases_readme_url)$suffix" - echo "" - echo "**Version:** \`$version\` | **Author:** $author | **Last Updated:** $(fmt_date "$last_updated")" - echo "" - echo "$description" - echo "" - # Build badges (license, discord, repo) - local discord_link="$discord_thread" - local badges="" - if [[ -n "$license" ]]; then - badges="[![License: $license](https://img.shields.io/badge/License-$(shields_encode "$license")-blue?style=flat-square)](https://spdx.org/licenses/${license}.html)" - fi - if [[ -n "$discord_link" ]]; then - [[ -n "$badges" ]] && badges+=" " - badges+="[![Discord](https://img.shields.io/badge/Discord-Discussion-5865F2?style=flat-square&logo=discord&logoColor=white)]($discord_link)" - fi - if [[ -n "$repo_url" ]]; then - [[ -n "$badges" ]] && badges+=" " - badges+="[![Repository](https://img.shields.io/badge/GitHub-Repository-181717?style=flat-square&logo=github&logoColor=white)]($repo_url)" - fi - if [[ -n "$badges" ]]; then - echo "$badges" - echo "" - fi - if [[ -n "$min_dispatcharr" || -n "$max_dispatcharr" ]]; then - compat_badges="" - if [[ -n "$min_dispatcharr" ]]; then - compat_badges="![Dispatcharr min](https://img.shields.io/badge/Dispatcharr_min-$(shields_encode "$min_dispatcharr")-brightgreen?style=flat-square)" - fi - if [[ -n "$max_dispatcharr" ]]; then - [[ -n "$compat_badges" ]] && compat_badges+=" " - compat_badges+="![Dispatcharr max](https://img.shields.io/badge/Dispatcharr_max-$(shields_encode "$max_dispatcharr")-orange?style=flat-square)" - fi - echo "$compat_badges" - echo "" - fi - echo "**Downloads:**" - if [[ -n "$zip_url" ]]; then - echo "- [Latest Release (\`$version\`)]($zip_url)" - fi - echo "- [All Versions ($version_count available)]($releases_dir)" - echo "" - - local footer="" - [[ -n "$maintainers" ]] && footer="**Maintainers:** $maintainers | " - footer+="**Source:** [Browse](${source_url})" - [[ "$has_source_readme" == "true" ]] && footer+=" | [README]($readme_url)" - footer+=" | **Last Change:** [\`$commit_sha_short\`]($commit_url)" - echo "$footer" - echo "" - echo "---" - echo "" -} - -{ - echo "# Plugin Releases" - echo "" - echo "This branch contains all published plugin releases." - echo "" - echo "## Quick Access" - echo "" - echo "- [manifest.json](./manifest.json) - Complete plugin registry with metadata" - echo "- [metadata/](./metadata/) - Per-plugin manifests and READMEs" - echo "" - echo "## Available Plugins" - echo "" - echo "| Plugin | Version | Author | License | Description |" - echo "|--------|---------|-------|---------|-------------|" - - # Table rows: active plugins first, then deprecated - for pass in active deprecated; do - for plugin_dir in plugins/*/; do - plugin_file="$plugin_dir/plugin.json" - [[ ! -f "$plugin_file" ]] && continue - deprecated=$(jq -r '.deprecated // false' "$plugin_file") - unlisted=$(jq -r '.unlisted // false' "$plugin_file") - [[ "$unlisted" == "true" ]] && continue - [[ "$pass" == "active" && "$deprecated" == "true" ]] && continue - [[ "$pass" == "deprecated" && "$deprecated" != "true" ]] && continue - - plugin_name=$(basename "$plugin_dir") - name=$(jq -r '.name' "$plugin_file") - version=$(jq -r '.version' "$plugin_file") - author=$(jq -r '.author // ""' "$plugin_file") - description=$(jq -r '.description' "$plugin_file") - table_license=$(jq -r '.license // "-"' "$plugin_file") - anchor=$(echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g') - suffix="" - [[ "$pass" == "deprecated" ]] && suffix=" (deprecated)" - license_cell="${table_license}" - - echo "| [\`$name\`](#$anchor)$suffix | \`$version\` | $author | $license_cell | $description |" - done - done - - echo "" - echo "---" - echo "" - - # Detailed sections: active plugins - for plugin_dir in plugins/*/; do - plugin_file="$plugin_dir/plugin.json" - [[ ! -f "$plugin_file" ]] && continue - deprecated=$(jq -r '.deprecated // false' "$plugin_file") - unlisted=$(jq -r '.unlisted // false' "$plugin_file") - [[ "$deprecated" == "true" ]] && continue - [[ "$unlisted" == "true" ]] && continue - - plugin_name=$(basename "$plugin_dir") - name=$(jq -r '.name' "$plugin_file") - version=$(jq -r '.version' "$plugin_file") - author=$(jq -r '.author // ""' "$plugin_file") - description=$(jq -r '.description' "$plugin_file") - maintainers=$(jq -r '[.maintainers[]?] | join(", ")' "$plugin_file") - last_updated=$(git log -1 --format=%cI origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null \ - || 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=$(jq -r '.manifest.versions | length' "metadata/$plugin_name/manifest.json" 2>/dev/null || echo "0") - 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") - repo_url=$(jq -r '.repo_url // ""' "$plugin_file") - discord_thread=$(jq -r '.discord_thread // ""' "$plugin_file") - - render_plugin "false" "$plugin_name" "$name" "$version" "$author" "$description" \ - "$maintainers" "$last_updated" "$commit_sha" "$commit_sha_short" "$version_count" "$plugin_license" \ - "$min_dispatcharr" "$max_dispatcharr" "$repo_url" "$discord_thread" - done - - # Deprecated section (only if any exist) - has_deprecated=false - for plugin_dir in plugins/*/; do - plugin_file="$plugin_dir/plugin.json" - [[ ! -f "$plugin_file" ]] && continue - if [[ "$(jq -r '.deprecated // false' "$plugin_file")" == "true" ]]; then - has_deprecated=true - break - fi - done - - if [[ "$has_deprecated" == "true" ]]; then - echo "" - echo "## Deprecated Plugins" - echo "" - echo "These plugins are deprecated and may be removed in the future." - echo "" - - for plugin_dir in plugins/*/; do - plugin_file="$plugin_dir/plugin.json" - [[ ! -f "$plugin_file" ]] && continue - deprecated=$(jq -r '.deprecated // false' "$plugin_file") - unlisted=$(jq -r '.unlisted // false' "$plugin_file") - [[ "$deprecated" != "true" ]] && continue - [[ "$unlisted" == "true" ]] && continue - - plugin_name=$(basename "$plugin_dir") - name=$(jq -r '.name' "$plugin_file") - version=$(jq -r '.version' "$plugin_file") - author=$(jq -r '.author // ""' "$plugin_file") - description=$(jq -r '.description' "$plugin_file") - maintainers=$(jq -r '[.maintainers[]?] | join(", ")' "$plugin_file") - last_updated=$(git log -1 --format=%cI origin/$SOURCE_BRANCH -- "$plugin_dir" 2>/dev/null \ - || 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 "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") - repo_url=$(jq -r '.repo_url // ""' "$plugin_file") - discord_thread=$(jq -r '.discord_thread // ""' "$plugin_file") - - render_plugin "true" "$plugin_name" "$name" "$version" "$author" "$description" \ - "$maintainers" "$last_updated" "$commit_sha" "$commit_sha_short" "$version_count" "$plugin_license" \ - "$min_dispatcharr" "$max_dispatcharr" "$repo_url" "$discord_thread" - done - fi - - echo "## Using the Manifest" - echo "" - echo "Fetch \`manifest.json\` to programmatically access plugin metadata and download URLs:" - echo "" - echo "\`\`\`bash" - echo "curl https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/$RELEASES_BRANCH/manifest.json" - echo "\`\`\`" - echo "" - echo "---" - echo "" - echo "*Last updated: $(date -u +"%b %d %Y, %H:%M UTC")*" -} > README.md diff --git a/.github/scripts/publish/run.sh b/.github/scripts/publish/run.sh deleted file mode 100644 index b7d792ff..00000000 --- a/.github/scripts/publish/run.sh +++ /dev/null @@ -1,203 +0,0 @@ -#!/bin/bash -set -e - -# publish-plugins.sh -# Orchestrates plugin publishing: sets up the releases branch working directory, -# runs each publish phase via subscripts, then commits and pushes. -# -# Usage: publish-plugins.sh -# -# Environment variables required: -# GITHUB_REPOSITORY - Full repository name (owner/repo) -# GITHUB_TOKEN - GitHub token with write access - -SOURCE_BRANCH=$1 - -if [[ -z "$SOURCE_BRANCH" ]]; then - echo "Usage: $0 " - exit 1 -fi - -RELEASES_BRANCH="releases" -MAX_VERSIONED_ZIPS=10 -RELEASES_BRANCH_VERSION=3 -SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" - -export SOURCE_BRANCH RELEASES_BRANCH MAX_VERSIONED_ZIPS - -echo "Publishing plugins from $SOURCE_BRANCH to $RELEASES_BRANCH" - -# Create temporary working directories -WORK_DIR=$(mktemp -d) -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" -cd "$WORK_DIR/repo" - -# 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..." -if [[ "${FORCE_REBUILD:-false}" == "true" && -n "${FORCE_REBUILD_PLUGIN:-}" ]]; then - # Targeted rebuild: delete all GitHub Releases for the named plugin so build-zips.sh - # treats it as new, then clear its per-plugin manifest so generate-manifest.sh - # rebuilds it from scratch. All other plugins are untouched. - if 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 - git checkout --orphan $RELEASES_BRANCH - git rm -rf . 2>/dev/null || true - git commit --allow-empty -m "Initialize $RELEASES_BRANCH branch" - fi - echo "Targeted force rebuild: deleting GitHub Releases for $FORCE_REBUILD_PLUGIN" - gh release list --repo "$GITHUB_REPOSITORY" --json tagName --limit 500 \ - | jq -r '.[].tagName' \ - | grep "^${FORCE_REBUILD_PLUGIN}-" \ - | xargs -I{} gh release delete {} --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag 2>/dev/null || true - rm -f "metadata/$FORCE_REBUILD_PLUGIN/manifest.json" -elif [[ "${FORCE_REBUILD:-false}" == "true" ]]; then - echo "Force rebuild requested - deleting all plugin GitHub Releases and resetting $RELEASES_BRANCH." - git fetch origin $SOURCE_BRANCH 2>/dev/null || true - git checkout "origin/$SOURCE_BRANCH" -- plugins 2>/dev/null || true - if [[ -d plugins ]]; then - for plugin_dir in plugins/*/; do - plugin_name=$(basename "$plugin_dir") - gh release list --repo "$GITHUB_REPOSITORY" --json tagName --limit 500 \ - | jq -r '.[].tagName' \ - | grep "^${plugin_name}-" \ - | xargs -I{} gh release delete {} --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag 2>/dev/null || true - done - rm -rf plugins - fi - # Reset the releases branch to a fresh orphan - 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 - git checkout --orphan $RELEASES_BRANCH - git rm -rf . 2>/dev/null || true - git commit --allow-empty -m "Initialize $RELEASES_BRANCH branch" -fi - -# Clean old top-level artifacts (regenerated each run) -rm -f manifest.json README.md - -# Fetch source branch and copy plugins -echo "Fetching plugins from $SOURCE_BRANCH..." -git fetch origin $SOURCE_BRANCH -git checkout origin/$SOURCE_BRANCH -- plugins - -mkdir -p zips - -# --- Version guard --- -# Ensure the releases branch was initialised with the current repo version. -# Skip during force rebuild — the branch is being rebuilt from scratch. -if [[ "${FORCE_REBUILD:-false}" != "true" ]]; then - current_branch_ver=$(cat REPO_VER 2>/dev/null || echo "") - if [[ "$current_branch_ver" != "$RELEASES_BRANCH_VERSION" ]]; then - echo "::error::Releases branch version mismatch." - echo "::error:: Expected : $RELEASES_BRANCH_VERSION" - echo "::error:: Found : ${current_branch_ver:-'(none — migration not run)'}" - echo "::error::Run the 'Migrate Releases to GitHub Releases' workflow first, then re-run." - exit 1 - fi -fi - -# --- Phases --- -echo "" -echo "=== Building ZIPs ===" -bash "$SCRIPT_DIR/build-zips.sh" - -echo "" -echo "=== Cleaning up old releases ===" -bash "$SCRIPT_DIR/cleanup.sh" - -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" - -# --- Commit and push --- -echo "" -echo "=== Committing ===" -rm -rf plugins -git rm -rf --cached plugins 2>/dev/null || true - -echo "$RELEASES_BRANCH_VERSION" > REPO_VER -git add metadata manifest.json README.md REPO_VER - -if git diff --cached --quiet; then - echo "No changes to commit." -else - # 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 per-plugin manifest.json) 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}" - - 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/publish/yank-version.sh b/.github/scripts/publish/yank-version.sh deleted file mode 100644 index 504e27ca..00000000 --- a/.github/scripts/publish/yank-version.sh +++ /dev/null @@ -1,283 +0,0 @@ -#!/bin/bash -set -e - -# yank-version.sh -# Removes a specific version of a plugin from the releases branch and regenerates -# manifests and READMEs. If the yanked version is the current latest, the previous -# version is promoted to latest. If it is the only version, the plugin is fully -# removed. A rollback PR is opened against the source branch when the latest (or -# only) version is yanked. -# -# Environment variables required: -# GITHUB_REPOSITORY - Full repository name (owner/repo) -# GITHUB_TOKEN - GitHub token with write access and PR creation permission -# YANK_PLUGIN - Plugin slug (e.g. dispatcharr-exporter) -# YANK_VERSION - Version to remove (e.g. 3.0.1) -# SOURCE_BRANCH - Source branch to fetch plugin metadata from (e.g. main) - -: "${GITHUB_REPOSITORY:?}" "${GITHUB_TOKEN:?}" "${YANK_PLUGIN:?}" "${YANK_VERSION:?}" "${SOURCE_BRANCH:?}" "${YANK_ISSUE:?}" - -RELEASES_BRANCH="releases" -SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" - -export SOURCE_BRANCH RELEASES_BRANCH GITHUB_REPOSITORY - -echo "Yanking $YANK_PLUGIN v$YANK_VERSION from $RELEASES_BRANCH (issue #$YANK_ISSUE)" - -# --- Validate issue --- -ISSUE_STATE=$(gh api "repos/$GITHUB_REPOSITORY/issues/$YANK_ISSUE" --jq '.state' 2>/dev/null || true) -if [[ -z "$ISSUE_STATE" ]]; then - echo "::error::Issue #$YANK_ISSUE not found in $GITHUB_REPOSITORY." - exit 1 -fi -if [[ "$ISSUE_STATE" != "open" ]]; then - echo "::error::Issue #$YANK_ISSUE is already $ISSUE_STATE. Only open issues can authorize a yank." - exit 1 -fi -echo " Issue #$YANK_ISSUE is open - proceeding." - -WORK_DIR=$(mktemp -d) -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" -cd "$WORK_DIR/repo" - -# Configure git identity -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 - 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 releases branch -if 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 - echo "::error::Releases branch does not exist." - exit 1 -fi - -ZIP_DIR="metadata/$YANK_PLUGIN" -RELEASE_TAG="${YANK_PLUGIN}-${YANK_VERSION}" -PLUGIN_MANIFEST="$ZIP_DIR/manifest.json" - -# --- Validate --- -if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::error::GitHub Release $RELEASE_TAG not found. Nothing to yank." - exit 1 -fi - -# Count remaining versioned releases (excluding the one being yanked and -latest) -REMAINING=$(gh release list --repo "$GITHUB_REPOSITORY" --json tagName --limit 500 \ - | jq -r '.[].tagName' \ - | grep "^${YANK_PLUGIN}-" \ - | grep -v "^${YANK_PLUGIN}-latest$" \ - | grep -v "^${RELEASE_TAG}$" || true) -REMAINING_COUNT=$(echo "$REMAINING" | grep -c . || true) - -# Determine if we are yanking the current latest -CURRENT_LATEST="" -if [[ -f "$PLUGIN_MANIFEST" ]]; then - CURRENT_LATEST=$(jq -r '.manifest.latest.version // ""' "$PLUGIN_MANIFEST" 2>/dev/null || true) -fi -IS_LATEST=false -[[ "$YANK_VERSION" == "$CURRENT_LATEST" ]] && IS_LATEST=true - -IS_LAST_VERSION=false -[[ "$REMAINING_COUNT" -eq 0 ]] && IS_LAST_VERSION=true - -echo " Current latest : ${CURRENT_LATEST:-unknown}" -echo " Is latest : $IS_LATEST" -echo " Remaining releases: $REMAINING_COUNT" -echo " Is last version : $IS_LAST_VERSION" - -# --- Fetch source branch + plugins dir (needed by manifest + readme scripts) --- -git fetch origin "$SOURCE_BRANCH" -git checkout "origin/$SOURCE_BRANCH" -- plugins - -# Determine source_type before deleting anything -SOURCE_TYPE=$(jq -r '.source_type // "local"' "plugins/$YANK_PLUGIN/plugin.json" 2>/dev/null || echo "local") - -# --- Perform the yank --- -if $IS_LAST_VERSION; then - echo "Last version - deleting all GitHub Releases for $YANK_PLUGIN." - gh release delete "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag - rm -rf "$ZIP_DIR" -else - echo "Deleting GitHub Release $RELEASE_TAG" - gh release delete "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag - - if $IS_LATEST; then - NEW_LATEST_VERSION=$(echo "$REMAINING" \ - | sed "s/^${YANK_PLUGIN}-//" \ - | sort -V -r \ - | head -1) - if [[ -z "$NEW_LATEST_VERSION" ]]; then - echo "::error::Could not find a replacement version to promote to latest." - exit 1 - fi - echo "Promoting $NEW_LATEST_VERSION to latest (manifest will be updated by generate-manifest.sh)" - fi -fi - -# --- Regenerate manifests and READMEs --- -rm -f manifest.json README.md - -echo "" -echo "=== Regenerating manifests ===" -bash "$SCRIPT_DIR/generate-manifest.sh" - -echo "" -echo "=== Regenerating per-plugin READMEs ===" -bash "$SCRIPT_DIR/plugin-readmes.sh" - -echo "" -echo "=== Regenerating releases README ===" -bash "$SCRIPT_DIR/releases-readme.sh" - -# --- Commit and push --- -echo "" -echo "=== Committing ===" -rm -rf plugins -git rm -rf --cached plugins 2>/dev/null || true - -git add metadata manifest.json README.md - -if git diff --cached --quiet; then - echo "No changes to commit - was this version already absent?" -else - git commit -m "Yank ${YANK_PLUGIN} v${YANK_VERSION} - -Refs #${YANK_ISSUE}" - RELEASES_COMMIT=$(git rev-parse --short HEAD) - git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" $RELEASES_BRANCH - echo "Successfully yanked ${YANK_PLUGIN} v${YANK_VERSION} from ${RELEASES_BRANCH}" -fi - -ROLLBACK_PR_URL="" - -# --- Open rollback PR if we yanked the latest (or only) version --- -if $IS_LATEST; then - echo "" - echo "=== Opening rollback PR ===" - - REPO_OWNER=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f1) - REPO_NAME=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f2) - ROLLBACK_BRANCH="yank/${YANK_PLUGIN}-${YANK_VERSION}" - - # Start rollback branch from source branch tip - git fetch origin "$SOURCE_BRANCH" - git checkout -b "$ROLLBACK_BRANCH" "origin/$SOURCE_BRANCH" - - if $IS_LAST_VERSION; then - # Remove the plugin from source entirely - git rm -rf "plugins/$YANK_PLUGIN" - PR_TITLE="[${YANK_PLUGIN}]: Remove plugin (all versions yanked)" - PR_BODY="## Plugin Removed - -All published versions of \`${YANK_PLUGIN}\` have been yanked from the releases branch. - -This PR removes the plugin from the source branch to prevent it from being republished on the next run. - -**Yanked version:** \`${YANK_VERSION}\` -**Authorized by:** #${YANK_ISSUE} - -Closes #${YANK_ISSUE}" - else - # Roll back plugin source to the new-latest version - if [[ "$SOURCE_TYPE" == "local" ]]; then - # Find the most recent commit where plugin.json had the target version - RESTORE_COMMIT=$(git log --all --format="%H" -- "plugins/$YANK_PLUGIN/plugin.json" | while IFS= read -r sha; do - v=$(git show "${sha}:plugins/${YANK_PLUGIN}/plugin.json" 2>/dev/null | jq -r '.version // ""' 2>/dev/null || true) - if [[ "$v" == "$NEW_LATEST_VERSION" ]]; then - echo "$sha" - break - fi - done) - - if [[ -n "$RESTORE_COMMIT" ]]; then - echo "Restoring plugins/$YANK_PLUGIN/ from commit $RESTORE_COMMIT" - git checkout "$RESTORE_COMMIT" -- "plugins/$YANK_PLUGIN/" - SOURCE_NOTE="Plugin source files restored from commit \`${RESTORE_COMMIT:0:7}\` (the last commit where \`${YANK_PLUGIN}\` was at \`${NEW_LATEST_VERSION}\`)." - else - echo "::warning::Could not find a commit for $YANK_PLUGIN v$NEW_LATEST_VERSION - falling back to version field update only." - jq --arg v "$NEW_LATEST_VERSION" '.version = $v' "plugins/$YANK_PLUGIN/plugin.json" > /tmp/plugin.json.tmp - mv /tmp/plugin.json.tmp "plugins/$YANK_PLUGIN/plugin.json" - SOURCE_NOTE="⚠️ **Could not restore source files** - no commit found for \`${NEW_LATEST_VERSION}\` (history may have been squashed). Only the \`version\` field was updated. Please review the source files manually before merging." - fi - else - # External plugin: only the version field needs to change - jq --arg v "$NEW_LATEST_VERSION" '.version = $v' "plugins/$YANK_PLUGIN/plugin.json" > /tmp/plugin.json.tmp - mv /tmp/plugin.json.tmp "plugins/$YANK_PLUGIN/plugin.json" - SOURCE_NOTE="Version field in \`plugin.json\` updated to \`${NEW_LATEST_VERSION}\`. The ZIP will be re-fetched from the external source URL on the next publish run." - fi - - git add "plugins/$YANK_PLUGIN/" - PR_TITLE="[${YANK_PLUGIN}]: Roll back to v${NEW_LATEST_VERSION} (yank of v${YANK_VERSION})" - PR_BODY="## Version Rollback - -\`${YANK_PLUGIN}\` \`${YANK_VERSION}\` has been yanked from the releases branch. This PR rolls the source back to \`${NEW_LATEST_VERSION}\` so the yanked version is not republished on the next run. - -**Yanked version:** \`${YANK_VERSION}\` -**New latest:** \`${NEW_LATEST_VERSION}\` -**Authorized by:** #${YANK_ISSUE} - -${SOURCE_NOTE} - -Closes #${YANK_ISSUE}" - fi - - git commit -m "$PR_TITLE" - git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$ROLLBACK_BRANCH" - - # Ensure the Rollback label exists - gh label create "Rollback" \ - --color "E11D48" \ - --description "Version rollback opened by the yank workflow" \ - --repo "$GITHUB_REPOSITORY" 2>/dev/null || true - - ROLLBACK_PR_URL=$(gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base "$SOURCE_BRANCH" \ - --head "$ROLLBACK_BRANCH" \ - --title "$PR_TITLE" \ - --label "Rollback" \ - --body "$PR_BODY") - - echo "Rollback PR opened: $ROLLBACK_BRANCH -> $SOURCE_BRANCH ($ROLLBACK_PR_URL)" -fi - -# --- Comment on the issue; close directly only if no rollback PR is pending --- -if $IS_LATEST; then - # Issue will be auto-closed when the rollback PR is merged (via Closes #N in PR body). - # Leave a comment pointing to the PR so the requester can track progress. - gh issue comment "$YANK_ISSUE" \ - --repo "$GITHUB_REPOSITORY" \ - --body "\`${YANK_PLUGIN}\` v\`${YANK_VERSION}\` has been removed from the releases branch (commit \`${RELEASES_COMMIT:-unknown}\`). - -A rollback PR has been opened to update the source branch: ${ROLLBACK_PR_URL:-"(see workflow run for link)"} - -This issue will close automatically when that PR is merged." -else - # No rollback PR - close the issue directly. - gh issue comment "$YANK_ISSUE" \ - --repo "$GITHUB_REPOSITORY" \ - --body "\`${YANK_PLUGIN}\` v\`${YANK_VERSION}\` has been removed from the releases branch (commit \`${RELEASES_COMMIT:-unknown}\`). - -This was not the latest version so no source branch changes are needed." - - gh issue close "$YANK_ISSUE" \ - --repo "$GITHUB_REPOSITORY" \ - --reason "completed" -fi diff --git a/.github/scripts/validate/detect-changes.sh b/.github/scripts/validate/detect-changes.sh deleted file mode 100755 index 9ed633cf..00000000 --- a/.github/scripts/validate/detect-changes.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/bin/bash -set -e - -# detect-changes.sh -# Detects which plugins were modified in a PR, checks .github/ protection, -# and determines whether the PR should be auto-closed for lack of permission. -# -# Usage: detect-changes.sh -# -# Outputs (written to $GITHUB_OUTPUT): -# matrix - JSON array of plugin names for matrix strategy -# plugin_count - Number of modified plugins -# close_pr - "true" if the PR should be auto-closed (no permission, no new plugins) -# -# Exit codes: -# 0 - OK (matrix emitted, proceed to validation) -# 1 - Hard block (e.g. .github/ modification by unauthorized user, no plugin changes) - -PR_AUTHOR=$1 -BASE_REF=$2 -HEAD_REF=${3:-} - -if [[ -z "$PR_AUTHOR" || -z "$BASE_REF" ]]; then - echo "Usage: $0 [head_ref]" - exit 1 -fi - -REPO_OWNER=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f1) -REPO_NAME=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f2) - -has_write_access() { - local author=$1 - local perm - perm=$(gh api repos/$REPO_OWNER/$REPO_NAME/collaborators/$author/permission --jq .permission 2>/dev/null || echo "none") - if [[ "$perm" == "admin" || "$perm" == "maintain" || "$perm" == "write" ]]; then - echo "1" - else - echo "0" - fi -} - -MERGE_BASE=$(git merge-base "origin/${BASE_REF}" HEAD) - -# --- Bot-authored yank rollback PRs bypass plugin validation --- -# The yank workflow opens PRs on branches named yank/* using the bot identity. -# These PRs intentionally roll back (or remove) plugin versions, so the normal -# version-bump and format checks do not apply. -if [[ "$PR_AUTHOR" == *"[bot]" && "$HEAD_REF" == yank/* ]]; then - echo "Bot-authored yank rollback PR detected ($PR_AUTHOR, $HEAD_REF) - skipping plugin validation." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - echo "plugin_count=0" >> "$GITHUB_OUTPUT" - echo "close_pr=false" >> "$GITHUB_OUTPUT" - echo "close_reason=" >> "$GITHUB_OUTPUT" - echo "skip_validation=true" >> "$GITHUB_OUTPUT" - echo "outside_violation=false" >> "$GITHUB_OUTPUT" - echo "pub_key_changed=false" >> "$GITHUB_OUTPUT" - echo "has_new_plugin=false" >> "$GITHUB_OUTPUT" - echo "has_updated_plugin=false" >> "$GITHUB_OUTPUT" - exit 0 -fi - -# --- Protection check: only plugins/ may be modified by non-maintainers --- -OUTSIDE_CHANGES=$(git diff --name-only "$MERGE_BASE" HEAD | grep -v '^plugins/' || true) -HAS_OUTSIDE_VIOLATION=0 -if [[ -n "$OUTSIDE_CHANGES" ]] && [[ "$(has_write_access "$PR_AUTHOR")" -ne 1 ]]; then - HAS_OUTSIDE_VIOLATION=1 -fi - -# --- Detect modified plugins --- -PLUGIN_LIST=$(git diff --name-only "$MERGE_BASE" HEAD \ - | grep '^plugins/' | cut -d '/' -f2 | sort -u) - -if [[ -z "$PLUGIN_LIST" ]]; then - if [[ $HAS_OUTSIDE_VIOLATION -eq 1 ]]; then - # Unauthorized outside-plugins changes with no plugin changes - echo "matrix=[]" >> "$GITHUB_OUTPUT" - echo "plugin_count=0" >> "$GITHUB_OUTPUT" - echo "close_pr=false" >> "$GITHUB_OUTPUT" - echo "close_reason=" >> "$GITHUB_OUTPUT" - echo "skip_validation=false" >> "$GITHUB_OUTPUT" - echo "outside_violation=true" >> "$GITHUB_OUTPUT" - echo "has_new_plugin=false" >> "$GITHUB_OUTPUT" - echo "has_updated_plugin=false" >> "$GITHUB_OUTPUT" - { - echo "outside_files<> "$GITHUB_OUTPUT" - exit 0 - fi - if [[ "$(has_write_access "$PR_AUTHOR")" -eq 1 ]]; then - # Repo maintainer with no plugin changes - skip plugin validation entirely and pass - PUB_KEY_CHANGED=false - if echo "$OUTSIDE_CHANGES" | grep -q "^\.github/scripts/keys/dispatcharr-plugins\.pub$"; then - PUB_KEY_CHANGED=true - fi - echo "matrix=[]" >> "$GITHUB_OUTPUT" - echo "plugin_count=0" >> "$GITHUB_OUTPUT" - echo "close_pr=false" >> "$GITHUB_OUTPUT" - echo "close_reason=" >> "$GITHUB_OUTPUT" - echo "skip_validation=true" >> "$GITHUB_OUTPUT" - echo "outside_violation=false" >> "$GITHUB_OUTPUT" - echo "pub_key_changed=$PUB_KEY_CHANGED" >> "$GITHUB_OUTPUT" - echo "has_new_plugin=false" >> "$GITHUB_OUTPUT" - echo "has_updated_plugin=false" >> "$GITHUB_OUTPUT" - if [[ -n "$OUTSIDE_CHANGES" ]]; then - { - echo "outside_files<> "$GITHUB_OUTPUT" - fi - echo "No plugin changes detected - skipping plugin validation (author has write access)." - exit 0 - fi - echo "::error::No plugin changes detected in this PR." - exit 1 -fi - -# --- Allowlist: only accept safe lowercase-kebab-case names before they enter the matrix --- -SAFE_LIST="" -while IFS= read -r plugin; do - [[ -z "$plugin" ]] && continue - if [[ "$plugin" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - SAFE_LIST+="${plugin}"$'\n' - else - echo "::warning::Skipping plugin with unsafe folder name: '${plugin}'" - fi -done <<< "$PLUGIN_LIST" -PLUGIN_LIST=$(printf '%s' "$SAFE_LIST" | grep . || true) - -if [[ -z "$PLUGIN_LIST" ]]; then - echo "::error::No valid plugin changes detected in this PR." - echo "close_pr=true" >> "$GITHUB_OUTPUT" - echo "close_reason=no-valid-plugins" >> "$GITHUB_OUTPUT" - echo "plugin_count=0" >> "$GITHUB_OUTPUT" - echo "matrix=[]" >> "$GITHUB_OUTPUT" - echo "has_new_plugin=false" >> "$GITHUB_OUTPUT" - echo "has_updated_plugin=false" >> "$GITHUB_OUTPUT" - exit 0 -fi - -PLUGIN_COUNT=$(echo "$PLUGIN_LIST" | wc -l | tr -d ' ') - -# --- Check if any modified plugin is new / updated --- -HAS_NEW_PLUGIN=0 -HAS_UPDATED_PLUGIN=0 -while IFS= read -r plugin; do - [[ -z "$plugin" ]] && continue - if ! git show "origin/${BASE_REF}:plugins/${plugin}/plugin.json" > /dev/null 2>&1; then - HAS_NEW_PLUGIN=1 - else - HAS_UPDATED_PLUGIN=1 - fi -done <<< "$PLUGIN_LIST" - -# --- Check if PR author has permission for at least one modified plugin --- -HAS_ANY_PERMISSION=0 -IS_REPO_MAINTAINER=$(has_write_access "$PR_AUTHOR") -if [[ "$IS_REPO_MAINTAINER" -eq 1 ]]; then - HAS_ANY_PERMISSION=1 -else - while IFS= read -r plugin; do - [[ -z "$plugin" ]] && continue - # Read from base branch to prevent self-granting permission via the PR itself - BASE_JSON=$(git show "origin/${BASE_REF}:plugins/${plugin}/plugin.json" 2>/dev/null || echo "") - if [[ -n "$BASE_JSON" ]]; then - AUTHOR=$(echo "$BASE_JSON" | jq -r '.author // ""') - MAINTAINERS=$(echo "$BASE_JSON" | jq -r '[.maintainers[]?] | join(" ")') - if [[ "$PR_AUTHOR" == "$AUTHOR" ]] || [[ " $MAINTAINERS " =~ " $PR_AUTHOR " ]]; then - HAS_ANY_PERMISSION=1 - break - fi - fi - # New plugins (no base version) are handled by HAS_NEW_PLUGIN above - done <<< "$PLUGIN_LIST" -fi - -# Determine if this PR should be auto-closed: -# Only close if the author has no permission AND there are no new plugins -CLOSE_PR="false" -if [[ $HAS_ANY_PERMISSION -eq 0 ]] && [[ $HAS_NEW_PLUGIN -eq 0 ]]; then - CLOSE_PR="true" -fi - -# Build JSON matrix array -MATRIX_JSON=$(echo "$PLUGIN_LIST" | jq -Rnc '[inputs]') - -echo "matrix=$MATRIX_JSON" >> "$GITHUB_OUTPUT" -echo "plugin_count=$PLUGIN_COUNT" >> "$GITHUB_OUTPUT" -echo "close_pr=$CLOSE_PR" >> "$GITHUB_OUTPUT" -echo "skip_validation=false" >> "$GITHUB_OUTPUT" -if [[ $HAS_OUTSIDE_VIOLATION -eq 1 ]]; then - echo "outside_violation=true" >> "$GITHUB_OUTPUT" -else - echo "outside_violation=false" >> "$GITHUB_OUTPUT" -fi -if [[ "$CLOSE_PR" == "true" ]]; then - echo "close_reason=unauthorized" >> "$GITHUB_OUTPUT" -else - echo "close_reason=" >> "$GITHUB_OUTPUT" -fi -if [[ -n "$OUTSIDE_CHANGES" ]]; then - { - echo "outside_files<> "$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 "has_new_plugin=$( [[ $HAS_NEW_PLUGIN -eq 1 ]] && echo true || echo false )" >> "$GITHUB_OUTPUT" -echo "has_updated_plugin=$( [[ $HAS_UPDATED_PLUGIN -eq 1 ]] && echo true || echo false )" >> "$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 deleted file mode 100755 index 4d3b60ca..00000000 --- a/.github/scripts/validate/report.sh +++ /dev/null @@ -1,405 +0,0 @@ -#!/bin/bash -set -e - -# aggregate-report.sh -# Combines per-plugin report fragments, posts the final PR comment, -# and optionally closes an unauthorized PR. -# -# Usage: aggregate-report.sh -# -# Arguments: -# pr_number - GitHub PR number -# pr_author - GitHub username of PR author -# plugin_count - Total number of plugins validated -# close_pr - "true" to close the PR after posting the comment -# fragments_dir - Directory containing per-plugin .md fragment files -# -# Environment variables required: -# GITHUB_REPOSITORY - Full repository name (owner/repo) -# GH_TOKEN - GitHub token for API access - -PR_NUMBER=$1 -PR_AUTHOR=$2 -PLUGIN_COUNT=$3 -CLOSE_PR=$4 -FRAGMENTS_DIR=${5:-.} - -if [[ -z "$PR_NUMBER" || -z "$PR_AUTHOR" || -z "$PLUGIN_COUNT" || -z "$CLOSE_PR" ]]; then - echo "Usage: $0 [fragments_dir]" - exit 1 -fi - -OVERALL_FAILED=0 - -if [[ -n "${TITLE_VALID:-}" && "${TITLE_VALID}" != "true" ]]; then - OVERALL_FAILED=1 -fi - -# Parse per-plugin report files -COMBINED_BODY="" -TABLE_HEADER="| name | version | description | author | maintainers |" -TABLE_SEP="|---|---|---|---|---|" -TABLE_ROWS="" -PLUGIN_LINKS="" - -for fragment in "$FRAGMENTS_DIR"/*.fragment.md; do - [[ -f "$fragment" ]] || continue - - # Check if fragment contains a failure marker - if grep -q "❌" "$fragment"; then - OVERALL_FAILED=1 - fi - - # Extract metadata table row from hidden comment marker - META_ROW=$(grep '//' || true) - if [[ -n "$META_ROW" ]]; then - IFS=$'\t' read -r f_name f_version f_description f_author f_maintainers f_repo_url f_discord_thread <<< "$META_ROW" - TABLE_ROWS+="| $f_name | $f_version | $f_description | $f_author | $f_maintainers |"$'\n' - if [[ -n "$f_repo_url" || -n "$f_discord_thread" ]]; then - PLUGIN_LINKS+="**\`${f_name}\`:**"$'\n' - [[ -n "$f_repo_url" ]] && PLUGIN_LINKS+="- [GitHub Repository](${f_repo_url})"$'\n' - [[ -n "$f_discord_thread" ]] && PLUGIN_LINKS+="- [Discord Thread](${f_discord_thread})"$'\n' - PLUGIN_LINKS+=$'\n' - fi - fi - - # Strip internal marker lines from visible output - VISIBLE=$(grep -v '" - echo "" - echo "# Plugin Validation Results" - echo "" - echo "**Modified plugins:** $PLUGIN_COUNT" - echo "" - - if [[ "${CLOSE_REASON:-}" == "no-valid-plugins" ]]; then - # echo "" - # echo "## Invalid Plugin Folder Name" - echo "" - echo "⚠️ Your PR modifies plugin folder(s) whose names do not meet the naming requirements. Plugin folder names must be **lowercase letters, numbers, and hyphens only** (e.g. \`my-plugin\`). Spaces and other special characters are not allowed." - echo "" - echo "Please rename the folder(s) and update your PR." - if [[ -n "${DISCORD_URL:-}" ]]; then - echo "" - echo "For help: [Dispatcharr Discord]($DISCORD_URL)" - fi - elif [[ "${CLOSE_REASON:-}" == "author-blacklisted" ]]; then - echo "" - echo "## PR Closed: Account Restricted" - echo "" - echo "Your GitHub account (\`$PR_AUTHOR\`) is not permitted to submit plugins to this repository. This PR has been automatically closed." - if [[ -n "${DISCORD_URL:-}" ]]; then - echo "" - echo "If you believe this is an error, please reach out via the [Dispatcharr Discord]($DISCORD_URL)." - fi - elif [[ "${CLOSE_REASON:-}" == "plugin-blacklisted" ]]; then - echo "" - echo "## PR Closed: Plugin Restricted" - echo "" - echo "One or more plugins in this PR are on the restricted list and cannot be submitted to this repository. This PR has been automatically closed." - if [[ -n "${DISCORD_URL:-}" ]]; then - echo "" - echo "If you believe this is an error, please reach out via the [Dispatcharr Discord]($DISCORD_URL)." - fi - elif [[ "$CLOSE_PR" == "true" ]]; then - echo "" - echo "## PR Closed: Unauthorized" - echo "" - echo "Your GitHub username (\`$PR_AUTHOR\`) does not appear in \`author\` or \`maintainers\` for any of the plugin(s) in this PR. This PR has been automatically closed." - echo "If you would like to contribute to this plugin, please consider reaching out to the maintainers of this plugin on Discord, or the plugin's Github repository." - echo "" - echo "If you are submitting a new plugin, add your GitHub username to the \`author\` field in your \`plugin.json\`." - if [[ -n "$PLUGIN_LINKS" ]]; then - echo "" - echo "### Plugin Contact Links" - echo "" - echo "$PLUGIN_LINKS" - fi - if [[ -n "${DISCORD_URL:-}" ]]; then - echo "" - echo "For general help or plugin discussion:" - echo "- [Dispatcharr Discord]($DISCORD_URL)" - fi - else - echo "$COMBINED_BODY" - - if [[ -n "${OUTSIDE_FILES:-}" && "${OUTSIDE_VIOLATION:-}" == "true" ]]; then - OVERALL_FAILED=1 - echo "" - echo "⚠️ This PR modifies files outside of \`plugins/\`, which requires write access to the repository. These changes will block merging." - echo "" - echo "External contributions to repository tooling and scripts are not accepted via PR. If you think something needs fixing, please [open an issue](https://github.com/${GITHUB_REPOSITORY}/issues/new/choose) instead." - echo "" - echo "**Modified files:**" - echo "\`\`\`" - echo "${OUTSIDE_FILES}" - echo "\`\`\`" - echo "" - echo "Remove these changes and resubmit with only modifications inside \`plugins/\`." - if [[ -n "${DISCORD_URL:-}" ]]; then - echo "" - echo "For help: [Dispatcharr Discord]($DISCORD_URL)" - fi - 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 embedded signatures in \`manifest.json\` files on the \`releases\` branch will be regenerated on next publish." - echo "" - fi - - # insert --- if there are ANY codeql/clamav findings, medium, low, or skip/unscanned notice - if [[ -n "${CODEQL_RESULT:-}" && "${CODEQL_RESULT:-}" != "skipped" && "${CODEQL_RESULT:-}" != "success" ]] || \ - [[ -n "${CODEQL_MEDIUMS:-}" && "${CODEQL_MEDIUMS}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]] || \ - [[ -n "${CODEQL_LOWS:-}" && "${CODEQL_LOWS}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]] || \ - [[ -n "${CODEQL_SUPPRESSED:-}" && "${CODEQL_SUPPRESSED}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]] || \ - [[ "${CODEQL_RESULT:-}" == "skipped" && -n "${CODEQL_UNSCANNED_LANGS:-}" ]] || \ - [[ "${CODEQL_RESULT:-}" != "skipped" && -n "${CODEQL_RESULT:-}" && -n "${CODEQL_UNSCANNED_LANGS:-}" ]] || \ - [[ "${CLAMAV_RESULT:-}" == "failure" ]]; then - echo "" - echo "---" - echo "" - fi - - if [[ "${CLAMAV_RESULT:-}" == "failure" ]]; then - OVERALL_FAILED=1 - INFECTED_LABEL="${CLAMAV_INFECTED:-unknown}" - echo "" - echo "❌ **ClamAV detected $INFECTED_LABEL infected file(s)**." - echo "" - if [[ -f "clamav-findings/clamav-findings.md" ]]; then - cat "clamav-findings/clamav-findings.md" - fi - fi - - if [[ -n "${CODEQL_RESULT:-}" && "${CODEQL_RESULT:-}" != "skipped" && "${CODEQL_RESULT:-}" != "success" ]]; then - # echo "" - # echo "## Code Quality" - echo "" - OVERALL_FAILED=1 - ERROR_LABEL="${CODEQL_ERRORS:-unknown}" - echo "❌ **CodeQL found $ERROR_LABEL high or critical issue(s)** - these must be fixed before merging." - echo "" - if [[ -f "codeql-findings/codeql-findings.md" ]]; then - cat "codeql-findings/codeql-findings.md" - fi - fi - - if [[ -n "${CODEQL_MEDIUMS:-}" && "${CODEQL_MEDIUMS:-}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]]; then - echo "" - echo "**CodeQL found ${CODEQL_MEDIUMS} medium severity issue(s)**" - echo "These are not blocking, but are included for visibility." - echo "" - if [[ -f "codeql-medium-findings/codeql-medium-findings.md" ]]; then - cat "codeql-medium-findings/codeql-medium-findings.md" - fi - fi - - if [[ -n "${CODEQL_LOWS:-}" && "${CODEQL_LOWS:-}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]]; then - echo "" - echo "
" - echo "CodeQL found ${CODEQL_LOWS} low severity or informational result(s)" - echo "These are not blocking, but are included for visibility." - echo "" - if [[ -f "codeql-low-findings/codeql-low-findings.md" ]]; then - cat "codeql-low-findings/codeql-low-findings.md" - fi - echo "" - echo "
" - fi - - if [[ -n "${CODEQL_SUPPRESSED:-}" && "${CODEQL_SUPPRESSED:-}" != "0" && "${CODEQL_RESULT:-}" != "skipped" ]]; then - echo "" - echo "**${CODEQL_SUPPRESSED} finding(s) suppressed via inline \`codeql[...]\` comment** - requires maintainer review before merging; auto-merge is blocked for this PR." - echo "" - if [[ -f "codeql-suppressed-findings/codeql-suppressed-findings.md" ]]; then - cat "codeql-suppressed-findings/codeql-suppressed-findings.md" - fi - fi - - # CodeQL skipped notice (when no scannable files exist but unscannable types were found) - if [[ "${CODEQL_RESULT:-}" == "skipped" && -n "${CODEQL_UNSCANNED_LANGS:-}" ]]; then - UNSCANNED_DISPLAY=$(echo "${CODEQL_UNSCANNED_LANGS}" | tr ',' ' ') - echo "" - echo "**CodeQL analysis was skipped** - no supported source files were found. The following bundled file type(s) are not covered by CodeQL: \`${UNSCANNED_DISPLAY}\`." - echo "" - elif [[ -n "${CODEQL_UNSCANNED_LANGS:-}" && "${CODEQL_RESULT:-}" != "skipped" && -n "${CODEQL_RESULT:-}" ]]; then - UNSCANNED_DISPLAY=$(echo "${CODEQL_UNSCANNED_LANGS}" | tr ',' ' ') - echo "" - echo "**Note:** The following bundled file type(s) were not scanned by CodeQL (unsupported language): \`${UNSCANNED_DISPLAY}\`." - echo "" - fi - - if [[ -n "${TITLE_VALID:-}" && "${TITLE_VALID}" != "true" ]]; then - echo "" - echo "---" - echo "" - echo "### ❌ PR Title Format" - echo "" - echo "${TITLE_FEEDBACK}" - if [[ -n "${TITLE_SUGGESTION:-}" ]]; then - echo "" - echo "**Suggested format:** \`${TITLE_SUGGESTION}\`" - fi - echo "" - fi - - echo "" - echo "---" - echo "" - if [[ $OVERALL_FAILED -eq 0 ]]; then - echo "## 🎉 All validation checks passed!" - echo "" - echo "This PR modifies **$PLUGIN_COUNT** plugin(s) and all checks have passed." - else - echo "## ❌ Validation failed" - echo "" - echo "Some checks failed. Please review the errors above and update your PR." - fi - - if [[ -n "$OTHER_PLUGINS_SECTION" ]]; then - echo "" - echo "---" - echo "" - echo "$OTHER_PLUGINS_SECTION" - fi - - # if [[ -n "$PLUGIN_LINKS" ]]; then - # echo "" - # echo "---" - # echo "" - # echo "## Plugin Links" - # echo "" - # echo "$PLUGIN_LINKS" - # fi - - # if [[ -n "$TABLE_ROWS" ]]; then - # echo "" - # echo "---" - # echo "" - # echo "## Plugin Metadata" - # echo "" - # echo "$TABLE_HEADER" - # echo "$TABLE_SEP" - # echo "$TABLE_ROWS" - # fi - 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=$? - -# Close PR for unauthorized plugin modifications -if [[ "$CLOSE_PR" == "true" && ( "${CLOSE_REASON:-}" == "unauthorized" || "${CLOSE_REASON:-}" == "author-blacklisted" || "${CLOSE_REASON:-}" == "plugin-blacklisted" ) ]]; then - gh pr close "$PR_NUMBER" - echo "PR #$PR_NUMBER closed: unauthorized" - exit $COMMENT_EXIT -fi - -exit $COMMENT_EXIT diff --git a/.github/scripts/validate/validate.sh b/.github/scripts/validate/validate.sh deleted file mode 100755 index 2dae7d48..00000000 --- a/.github/scripts/validate/validate.sh +++ /dev/null @@ -1,425 +0,0 @@ -#!/bin/bash -set -e - -# validate-single-plugin.sh -# Validates one plugin and writes a markdown report fragment to a file. -# -# Usage: validate-single-plugin.sh -# -# Arguments: -# plugin_name - Plugin folder name (e.g. my-plugin) -# pr_author - GitHub username of PR author -# base_ref - Base branch reference (e.g. main) -# output_file - File path to write the markdown report fragment to -# -# Outputs (written to $GITHUB_OUTPUT): -# result - "pass" or "fail" -# is_new - "true" if this is a new plugin (not on base branch) -# has_permission - "true" if pr_author is permitted to modify this plugin -# -# Environment variables required: -# GITHUB_REPOSITORY - Full repository name (owner/repo) -# GH_TOKEN - GitHub token for API access - -PLUGIN_NAME=$1 -PR_AUTHOR=$2 -BASE_REF=$3 -OUTPUT_FILE=${4:-/dev/stdout} - -if [[ -z "$PLUGIN_NAME" || -z "$PR_AUTHOR" || -z "$BASE_REF" ]]; then - echo "Usage: $0 [output_file]" - exit 1 -fi - -REPO_OWNER=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f1) -REPO_NAME=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f2) - -PLUGIN_DIR="plugins/$PLUGIN_NAME" -PLUGIN_JSON="$PLUGIN_DIR/plugin.json" -README="$PLUGIN_DIR/README.md" - -check_repo_maintainer() { - local author=$1 - PERMISSION=$(gh api repos/$REPO_OWNER/$REPO_NAME/collaborators/$author/permission --jq .permission 2>/dev/null || echo "none") - if [[ "$PERMISSION" == "admin" || "$PERMISSION" == "maintain" || "$PERMISSION" == "write" ]]; then - echo "1" - else - echo "0" - fi -} - -validate_semver() { - local version=$1 - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "1"; else echo "0"; fi -} - -validate_dispatcharr_version() { - local version=$1 - if [[ "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "1"; else echo "0"; fi -} - -version_greater_than() { - local new_version=$1 - local old_version=$2 - IFS='.' read -r NEW_MAJOR NEW_MINOR NEW_PATCH <<< "$new_version" - IFS='.' read -r OLD_MAJOR OLD_MINOR OLD_PATCH <<< "$old_version" - if (( 10#$NEW_MAJOR > 10#$OLD_MAJOR )); then return 0; fi - if (( 10#$NEW_MAJOR < 10#$OLD_MAJOR )); then return 1; fi - if (( 10#$NEW_MINOR > 10#$OLD_MINOR )); then return 0; fi - if (( 10#$NEW_MINOR < 10#$OLD_MINOR )); then return 1; fi - if (( 10#$NEW_PATCH > 10#$OLD_PATCH )); then return 0; fi - return 1 -} - -failed=0 -is_new="false" -has_permission="false" - -{ - echo "### Plugin: \`$PLUGIN_NAME\`" - echo "" - # Show description and repo link as header subtext if plugin.json is readable - if [[ -f "$PLUGIN_JSON" ]]; then - _desc=$(jq -r '.description // ""' "$PLUGIN_JSON" 2>/dev/null || true) - REPO_URL=$(jq -r '.repo_url // ""' "$PLUGIN_JSON" 2>/dev/null || true) - if [[ -n "$_desc" ]]; then - echo "_${_desc}_" - echo "" - fi - if [[ -n "$REPO_URL" ]]; then - echo "[Source Repository]($REPO_URL)" - echo "" - fi - fi - - TABLE_ROWS=() - - print_table() { - echo "| Check | Status | Details |" - echo "|-------|:------:|---------|" - for row in "${TABLE_ROWS[@]}"; do - echo "$row" - done - echo "" - } - - # ── Structural checks (hidden if pass) ─────────────────────────────────────── - - # Folder name format - if [[ ! "$PLUGIN_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - TABLE_ROWS+=("| Folder name | ❌ | Must be lowercase-kebab-case - got \`$PLUGIN_NAME\`, e.g. \`my-plugin-name\` |") - failed=1 - fi - - # plugin.json existence (early exit) - if [[ ! -f "$PLUGIN_JSON" ]]; then - TABLE_ROWS+=("| \`plugin.json\` | ❌ | File missing |") - print_table - echo "result=fail" >> "$GITHUB_OUTPUT" - echo "is_new=false" >> "$GITHUB_OUTPUT" - echo "has_permission=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # JSON syntax (early exit) - if ! jq empty "$PLUGIN_JSON" 2>/dev/null; then - TABLE_ROWS+=("| JSON syntax | ❌ | Invalid JSON in plugin.json |") - print_table - echo "result=fail" >> "$GITHUB_OUTPUT" - echo "is_new=false" >> "$GITHUB_OUTPUT" - echo "has_permission=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # ── Required content ────────────────────────────────────────────────────────── - - # Required fields: name, version, description - shown as a combined row - MISSING_FIELDS=() - for key in name version description; do - if ! jq -e ".\"$key\"" "$PLUGIN_JSON" >/dev/null 2>&1; then - MISSING_FIELDS+=("\`$key\`") - failed=1 - fi - done - if [[ ${#MISSING_FIELDS[@]} -gt 0 ]]; then - MISSING_LIST=$(IFS=", "; echo "${MISSING_FIELDS[*]}") - TABLE_ROWS+=("| Required fields | ❌ | Missing: $MISSING_LIST |") - else - TABLE_ROWS+=("| Required fields | ✅ | All required fields present |") - fi - - # Extract metadata - AUTHOR=$(jq -r '.author // ""' "$PLUGIN_JSON") - MAINTAINERS=$(jq -r '[.maintainers[]?] | join(" ")' "$PLUGIN_JSON") - VERSION=$(jq -r '.version' "$PLUGIN_JSON") - - # Pre-fetch base branch plugin.json once - reused for version bump check and compare link - _base_pjson="" - OLD_VERSION="" - OLD_SOURCE_URL_TMPL="" - if git show "origin/${BASE_REF}:${PLUGIN_JSON}" > /dev/null 2>&1; then - _base_pjson=$(git show "origin/${BASE_REF}:${PLUGIN_JSON}") - OLD_VERSION=$(echo "$_base_pjson" | jq -r '.version // ""') - OLD_SOURCE_URL_TMPL=$(echo "$_base_pjson" | jq -r '.source_url // ""') - fi - - # ── External source checks ──────────────────────────────────────────────────── - source_type=$(jq -r '.source_type // "local"' "$PLUGIN_JSON") - release_link="" - compare_link="" - _gh_tag="" - _old_gh_tag="" - if [[ "$source_type" != "external" ]]; then - _src_count=$(find "$PLUGIN_DIR" -type f \ - ! -name 'plugin.json' ! -name 'README.md' ! -name 'logo.png' \ - | wc -l | tr -d ' ') - if [[ "$_src_count" -eq 0 ]]; then - TABLE_ROWS+=("| Plugin files | ❌ | No source files found in \`plugins/$PLUGIN_NAME/\`. Add your plugin source (e.g. \`main.py\`) or set \`\"source_type\": \"external\"\` in \`plugin.json\` if your plugin is hosted elsewhere |") - failed=1 - fi - fi - if [[ "$source_type" == "external" ]]; then - ext_source_url=$(jq -r '.source_url // ""' "$PLUGIN_JSON") - ext_repo_url=$(jq -r '.repo_url // ""' "$PLUGIN_JSON") - - if [[ -z "$ext_source_url" ]]; then - TABLE_ROWS+=("| \`source_url\` | ❌ | Required when \`source_type\` is \`external\` |") - failed=1 - elif [[ ! "$ext_source_url" =~ ^https:// ]]; then - TABLE_ROWS+=("| \`source_url\` | ❌ | Must be an HTTPS URL |") - failed=1 - elif [[ "$ext_source_url" != *"{version}"* ]]; then - TABLE_ROWS+=("| \`source_url\` | ❌ | Must contain a \`{version}\` placeholder (e.g. \`.../v{version}/plugin.zip\`) |") - failed=1 - else - # TABLE_ROWS+=("| \`source_url\` | ✅ | \`${ext_source_url}\` |") - if [[ $(validate_semver "$VERSION") -eq 1 ]]; then - resolved_url="${ext_source_url//\{version\}/$VERSION}" - http_code=$(curl -o /dev/null -s -w "%{http_code}" --max-time 15 -L "$resolved_url" || echo "000") - if [[ "$http_code" == "200" ]]; then - TABLE_ROWS+=("| Release artifact | ✅ | Artifact reachable at resolved URL |") - if [[ "$resolved_url" =~ ^https://github\.com/([^/]+)/([^/]+)/releases/download/([^/]+)/ ]]; then - _gh_owner="${BASH_REMATCH[1]}" - _gh_repo="${BASH_REMATCH[2]}" - _gh_tag="${BASH_REMATCH[3]}" - release_link="https://github.com/${_gh_owner}/${_gh_repo}/releases/tag/${_gh_tag}" - if [[ -n "$OLD_VERSION" && -n "$OLD_SOURCE_URL_TMPL" ]]; then - _old_resolved="${OLD_SOURCE_URL_TMPL//\{version\}/$OLD_VERSION}" - if [[ "$_old_resolved" =~ ^https://github\.com/[^/]+/[^/]+/releases/download/([^/]+)/ ]]; then - _old_gh_tag="${BASH_REMATCH[1]}" - compare_link="https://github.com/${_gh_owner}/${_gh_repo}/compare/${_old_gh_tag}...${_gh_tag}" - fi - fi - fi - else - TABLE_ROWS+=("| Release artifact | ❌ | Could not reach \`$resolved_url\` (HTTP \`$http_code\`) — ensure the release exists |") - failed=1 - fi - fi - fi - - if [[ -z "$ext_repo_url" ]]; then - TABLE_ROWS+=("| \`repo_url\` | ❌ | Required for external plugins — set to the upstream source repository URL |") - failed=1 - fi - fi - - # Maintainers - if [[ -z "$AUTHOR" ]] && [[ -z "$MAINTAINERS" ]]; then - TABLE_ROWS+=("| Maintainers | ❌ | At least one of \`author\` or \`maintainers\` must include your GitHub username |") - failed=1 - else - DISPLAY_PARTS=() - [[ -n "$AUTHOR" ]] && DISPLAY_PARTS+=("\`$AUTHOR\`") - for m in $MAINTAINERS; do DISPLAY_PARTS+=("\`$m\`"); done - DISPLAY=$(printf '%s, ' "${DISPLAY_PARTS[@]}"); DISPLAY="${DISPLAY%, }" - TABLE_ROWS+=("| Maintainers | ✅ | $DISPLAY |") - fi - - # License (required) - LICENSE_ID=$(jq -r '.license // ""' "$PLUGIN_JSON") - if [[ -z "$LICENSE_ID" ]]; then - TABLE_ROWS+=("| License | ❌ | \`license\` is required - provide an [OSI-approved SPDX identifier](https://spdx.org/licenses/) (e.g. \`MIT\`, \`Apache-2.0\`) |") - failed=1 - else - SPDX_JSON=$(curl -fsSL "https://raw.githubusercontent.com/spdx/license-list-data/main/json/licenses.json" 2>/dev/null || echo "") - if [[ -z "$SPDX_JSON" ]]; then - TABLE_ROWS+=("| License | ⚠️ | Could not fetch SPDX license list - skipping validation |") - else - SPDX_VALID=$(echo "$SPDX_JSON" | jq --arg lid "$LICENSE_ID" '[.licenses[] | select(.isOsiApproved == true) | .licenseId] | any(. == $lid)') - if [[ "$SPDX_VALID" == "true" ]]; then - LICENSE_NAME=$(echo "$SPDX_JSON" | jq -r --arg lid "$LICENSE_ID" '.licenses[] | select(.licenseId == $lid) | .name') - TABLE_ROWS+=("| License | ✅ | \`$LICENSE_ID\` - $LICENSE_NAME |") - else - TABLE_ROWS+=("| License | ❌ | \`$LICENSE_ID\` is not an [OSI-approved SPDX identifier](https://spdx.org/licenses/) |") - failed=1 - fi - fi - fi - - # ── Access control ──────────────────────────────────────────────────────────── - - # Permission check - use base branch version to prevent self-granting via the PR - IS_REPO_MAINTAINER=$(check_repo_maintainer "$PR_AUTHOR") - if [[ "$IS_REPO_MAINTAINER" -eq 1 ]]; then - TABLE_ROWS+=("| Permission | ✅ | You have permission to modify this plugin |") - has_permission="true" - elif git show "origin/${BASE_REF}:${PLUGIN_JSON}" > /dev/null 2>&1; then - BASE_JSON=$(git show "origin/${BASE_REF}:${PLUGIN_JSON}") - BASE_AUTHOR=$(echo "$BASE_JSON" | jq -r '.author // ""') - BASE_MAINTAINERS=$(echo "$BASE_JSON" | jq -r '[.maintainers[]?] | join(" ")') - if [[ "$PR_AUTHOR" == "$BASE_AUTHOR" ]] || [[ " $BASE_MAINTAINERS " =~ " $PR_AUTHOR " ]]; then - TABLE_ROWS+=("| Permission | ✅ | You have permission to modify this plugin |") - has_permission="true" - else - TABLE_ROWS+=("| Permission | ❌ | \`$PR_AUTHOR\` is not listed in \`author\` or \`maintainers\` |") - failed=1 - fi - else - # New plugin - PR author must list themselves so future PRs can be authorized - if [[ "$PR_AUTHOR" == "$AUTHOR" ]] || [[ " $MAINTAINERS " =~ " $PR_AUTHOR " ]]; then - TABLE_ROWS+=("| Permission | ✅ | New plugin - \`$PR_AUTHOR\` listed in \`author\`/\`maintainers\` |") - has_permission="true" - else - TABLE_ROWS+=("| Permission | ❌ | Add \`\"author\": \"$PR_AUTHOR\"\` to plugin.json |") - failed=1 - fi - fi - - # ── Version checks ──────────────────────────────────────────────────────────── - - # Version format - if [[ $(validate_semver "$VERSION") -eq 1 ]]; then - TABLE_ROWS+=("| Version | ✅ | \`$VERSION\` |") - else - TABLE_ROWS+=("| Version | ❌ | \`$VERSION\` is not valid semver - expected \`X.Y.Z\` |") - failed=1 - 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 [[ -n "$_base_pjson" ]]; then - if version_greater_than "$VERSION" "$OLD_VERSION"; then - TABLE_ROWS+=("| Version bump | ✅ | \`$OLD_VERSION\` → \`$VERSION\` |") - else - # Version unchanged - check if every changed field is in the metadata-only allowlist - OLD_JSON="$_base_pjson" - 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) |") - else - # Any non-metadata plugin.json change or other file change requires a version bump. - # If plugin.json is identical, check whether other files in the directory changed - # so we can give a more accurate error message. - _other_changed="" - if [[ -z "$changed_fields" ]]; then - _other_changed=$(git diff --name-only "origin/${BASE_REF}...HEAD" -- "$PLUGIN_DIR" \ - | grep -v "^${PLUGIN_JSON}$" | head -1 || true) - fi - if [[ -z "$changed_fields" && -z "$_other_changed" ]]; then - TABLE_ROWS+=("| Version bump | ❌ | No changes detected - nothing to publish |") - else - TABLE_ROWS+=("| Version bump | ❌ | \`$VERSION\` must be greater than current \`$OLD_VERSION\` |") - fi - failed=1 - fi - fi - else - TABLE_ROWS+=("| Version bump | ✅ | New plugin |") - is_new="true" - fi - - # Dispatcharr version constraints (optional, hidden if pass) - MIN_DA_VERSION=$(jq -r '.min_dispatcharr_version // ""' "$PLUGIN_JSON") - MAX_DA_VERSION=$(jq -r '.max_dispatcharr_version // ""' "$PLUGIN_JSON") - - if [[ -n "$MIN_DA_VERSION" ]] && [[ $(validate_dispatcharr_version "$MIN_DA_VERSION") -eq 0 ]]; then - TABLE_ROWS+=("| \`min_dispatcharr_version\` | ❌ | \`$MIN_DA_VERSION\` is not valid semver - expected \`X.Y.Z\` or \`vX.Y.Z\` |") - failed=1 - fi - - if [[ -n "$MAX_DA_VERSION" ]] && [[ $(validate_dispatcharr_version "$MAX_DA_VERSION") -eq 0 ]]; then - TABLE_ROWS+=("| \`max_dispatcharr_version\` | ❌ | \`$MAX_DA_VERSION\` is not valid semver - expected \`X.Y.Z\` or \`vX.Y.Z\` |") - failed=1 - fi - - # min/max range sanity (hidden if pass) - if [[ -n "$MIN_DA_VERSION" && -n "$MAX_DA_VERSION" ]]; then - if [[ $(validate_dispatcharr_version "$MAX_DA_VERSION") -eq 1 && $(validate_dispatcharr_version "$MIN_DA_VERSION") -eq 1 ]]; then - _max="${MAX_DA_VERSION#v}" - _min="${MIN_DA_VERSION#v}" - if ! version_greater_than "$_max" "$_min" && [[ "$_max" != "$_min" ]]; then - TABLE_ROWS+=("| Version range | ❌ | \`max_dispatcharr_version\` (\`$MAX_DA_VERSION\`) must be ≥ \`min_dispatcharr_version\` (\`$MIN_DA_VERSION\`) |") - failed=1 - fi - fi - fi - - # ── Optional link fields (hidden if pass) ──────────────────────────────────── - - DISCORD_THREAD=$(jq -r '.discord_thread // ""' "$PLUGIN_JSON") - - if [[ -n "$REPO_URL" ]] && [[ ! "$REPO_URL" =~ ^https?:// ]]; then - TABLE_ROWS+=("| \`repo_url\` | ❌ | Must start with \`http://\` or \`https://\` |") - failed=1 - fi - - if [[ -n "$DISCORD_THREAD" ]] && [[ ! "$DISCORD_THREAD" =~ ^https?:// ]]; then - TABLE_ROWS+=("| \`discord_thread\` | ❌ | Must start with \`http://\` or \`https://\` |") - failed=1 - fi - - print_table - - if [[ -n "$release_link" ]]; then - _link_line="[View release ${_gh_tag} on GitHub](${release_link})" - [[ -n "$compare_link" ]] && _link_line+=" · [Compare ${_old_gh_tag}...${_gh_tag}](${compare_link})" - echo "$_link_line" - echo "" - fi - - # Metadata row (tab-delimited, consumed by aggregate-report.sh) - echo "" - -} > "$OUTPUT_FILE" - - -# Write job outputs -if [[ $failed -eq 0 ]]; then - echo "result=pass" >> "$GITHUB_OUTPUT" -else - echo "result=fail" >> "$GITHUB_OUTPUT" -fi -echo "is_new=$is_new" >> "$GITHUB_OUTPUT" -echo "has_permission=$has_permission" >> "$GITHUB_OUTPUT" - -exit $failed diff --git a/.github/workflows/_clamav-scan.yml b/.github/workflows/_clamav-scan.yml new file mode 100644 index 00000000..65c09158 --- /dev/null +++ b/.github/workflows/_clamav-scan.yml @@ -0,0 +1,203 @@ +name: ClamAV Scan (reusable) + +# Reusable workflow called by validate-plugin.yml. Virus-scans the changed +# plugins of a PR merge commit. pluginctl runs from the base branch (_trusted), +# never from the untrusted merge tree. + +on: + workflow_call: + inputs: + matrix: + required: true + type: string + pr_number: + required: true + type: string + base_ref: + required: true + type: string + metadata_only: + required: true + type: string + gh_app_id: + required: false + type: string + secrets: + gh_app_private_key: + required: false + outputs: + clamav_status: + value: ${{ jobs.scan.outputs.clamav_status }} + clamav_infected: + value: ${{ jobs.scan.outputs.clamav_infected }} + +permissions: + contents: read + pull-requests: write + issues: write + +env: + PYTHONPATH: ${{ github.workspace }}/_trusted/.github/pluginctl/src + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + clamav_status: ${{ steps.status.outputs.clamav_status }} + clamav_infected: ${{ steps.status.outputs.clamav_infected }} + steps: + - name: Checkout PR merge commit for scan + uses: actions/checkout@v6 + with: + ref: refs/pull/${{ inputs.pr_number }}/merge + sparse-checkout: .gitignore + sparse-checkout-cone-mode: false + # Merge ref is only virus-scanned by ClamAV; fork code is not executed. + allow-unsafe-pr-checkout: true + + - name: Checkout trusted pluginctl (base) + uses: actions/checkout@v6 + with: + repository: ${{ github.repository }} + ref: ${{ inputs.base_ref }} + path: _trusted + sparse-checkout: | + .github/pluginctl + .github/actions/gh-app-token + sparse-checkout-cone-mode: false + + - name: GitHub App token + id: token + uses: ./_trusted/.github/actions/gh-app-token + with: + app-id: ${{ inputs.gh_app_id }} + private-key: ${{ secrets.gh_app_private_key }} + + - name: Limit checkout to changed plugin folders only + run: | + echo '${{ inputs.matrix }}' | jq -r '.[] | "plugins/\(.)"' | git sparse-checkout set --stdin + git checkout + + - name: Get cache keys + id: cache-keys + run: | + echo "week=$(date +%Y-W%V)" >> $GITHUB_OUTPUT + echo "date=$(date +%Y%m%d)" >> $GITHUB_OUTPUT + + - name: Cache ClamAV installation (weekly) + id: cache-clamav-install + uses: actions/cache@v5 + with: + path: /tmp/clamav-apt + key: clamav-install-${{ runner.os }}-${{ steps.cache-keys.outputs.week }} + restore-keys: clamav-install-${{ runner.os }}- + + - name: Cache ClamAV virus definitions (daily) + id: cache-clamav-defs + uses: actions/cache@v5 + with: + path: /tmp/clamav-db + key: clamav-defs-${{ runner.os }}-${{ steps.cache-keys.outputs.date }} + restore-keys: clamav-defs-${{ runner.os }}- + + - name: Install ClamAV + run: | + sudo apt-get update -qq + if [[ "${{ steps.cache-clamav-install.outputs.cache-hit }}" == 'true' ]]; then + echo "Installing ClamAV from cached packages..." + sudo cp /tmp/clamav-apt/*.deb /var/cache/apt/archives/ 2>/dev/null || true + fi + sudo apt-get install -y --no-install-recommends clamav + if [[ "${{ steps.cache-clamav-install.outputs.cache-hit }}" != 'true' ]]; then + echo "Saving ClamAV packages to cache..." + mkdir -p /tmp/clamav-apt + find /var/cache/apt/archives/ \( -name 'clamav*.deb' -o -name 'libclamav*.deb' \) \ + -exec cp {} /tmp/clamav-apt/ \; 2>/dev/null || true + fi + + - name: Update virus definitions + run: | + sudo systemctl stop clamav-freshclam 2>/dev/null || true + sudo mkdir -p /tmp/clamav-db + sudo chown -R clamav:clamav /tmp/clamav-db + if [[ "${{ steps.cache-clamav-defs.outputs.cache-hit }}" != 'true' ]]; then + echo "Downloading fresh ClamAV definitions..." + sudo freshclam --datadir=/tmp/clamav-db + else + echo "Using cached ClamAV definitions" + fi + + - name: Scan changed plugin directories + id: scan + run: | + SCAN_TARGETS=() + EXT_SCAN_DIR="/tmp/external-scan" + mkdir -p "$EXT_SCAN_DIR" + while IFS= read -r plugin_name; do + plugin_json="plugins/$plugin_name/plugin.json" + [[ ! -f "$plugin_json" ]] && continue + source_type=$(jq -r '.source_type // "local"' "$plugin_json") + if [[ "$source_type" == "external" && "${{ inputs.metadata_only }}" != "true" ]]; then + version=$(jq -r '.version' "$plugin_json") + source_url_template=$(jq -r '.source_url // ""' "$plugin_json") + source_url="${source_url_template//\{version\}/$version}" + echo "Downloading external ZIP for ClamAV scan: $source_url" + scan_dir="$EXT_SCAN_DIR/$plugin_name" + mkdir -p "$scan_dir/extracted" + zip_file="$scan_dir/${plugin_name}.zip" + if curl -fsSL "$source_url" -o "$zip_file" --max-time 60; then + unzip -q "$zip_file" -d "$scan_dir/extracted" || true + SCAN_TARGETS+=("$scan_dir/extracted") + else + echo "::warning::Could not download $source_url, scanning plugin.json only for $plugin_name" + SCAN_TARGETS+=("plugins/$plugin_name") + fi + else + if [[ "$source_type" == "external" ]]; then + echo "Metadata-only update, scanning checked-out metadata for $plugin_name" + fi + SCAN_TARGETS+=("plugins/$plugin_name") + fi + done < <(echo '${{ inputs.matrix }}' | jq -r '.[]') + echo "Scanning: ${SCAN_TARGETS[*]}" + set +e + clamscan --database=/tmp/clamav-db \ + --recursive --infected --no-summary \ + "${SCAN_TARGETS[@]}" > clamav-output.txt 2>&1 + echo "exit_code=$?" >> $GITHUB_OUTPUT + set -e + cat clamav-output.txt + + - name: Parse ClamAV output and set status + id: status + if: always() + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + run: python -m pluginctl clamav-report --output clamav-output.txt --scan-exit "${{ steps.scan.outputs.exit_code }}" + + - name: Upload ClamAV findings for PR comment + if: always() && steps.status.outputs.clamav_status == 'failure' + uses: actions/upload-artifact@v7 + with: + name: clamav-findings + path: clamav-findings.md + if-no-files-found: ignore + + - name: Apply quarantine label + if: always() && steps.status.outputs.clamav_status == 'failure' + env: + GH_TOKEN: ${{ steps.token.outputs.token || github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + gh label create "QUARANTINE" \ + --color "FFFF00" \ + --description "ClamAV detected a potential threat in this PR" \ + --repo "$GITHUB_REPOSITORY" 2>/dev/null || true + gh pr edit "${{ inputs.pr_number }}" \ + --add-label "QUARANTINE" \ + --repo "$GITHUB_REPOSITORY" + + - name: Fail job if ClamAV detected threats + if: always() && steps.status.outputs.clamav_status == 'failure' + run: exit 1 diff --git a/.github/workflows/_codeql-scan.yml b/.github/workflows/_codeql-scan.yml new file mode 100644 index 00000000..0e497c9b --- /dev/null +++ b/.github/workflows/_codeql-scan.yml @@ -0,0 +1,217 @@ +name: CodeQL Scan (reusable) + +# Reusable workflow called by validate-plugin.yml. Runs CodeQL over the changed +# plugins of a PR merge commit and turns the SARIF into PR-comment inputs. +# pluginctl always runs from the base branch (checked out into _trusted), never +# from the untrusted merge tree. + +on: + workflow_call: + inputs: + matrix: + required: true + type: string + pr_number: + required: true + type: string + base_ref: + required: true + type: string + outputs: + codeql_status: + value: ${{ jobs.analyze.outputs.codeql_status }} + codeql_errors: + value: ${{ jobs.analyze.outputs.codeql_errors }} + codeql_warnings: + value: ${{ jobs.analyze.outputs.codeql_warnings }} + codeql_mediums: + value: ${{ jobs.analyze.outputs.codeql_mediums }} + codeql_lows: + value: ${{ jobs.analyze.outputs.codeql_lows }} + codeql_unscanned_langs: + value: ${{ jobs.analyze.outputs.codeql_unscanned_langs }} + codeql_suppressed: + value: ${{ jobs.analyze.outputs.codeql_suppressed }} + codeql_sandbox_bypass: + value: ${{ jobs.analyze.outputs.codeql_sandbox_bypass }} + codeql_sandbox_bypass_detected: + value: ${{ jobs.analyze.outputs.codeql_sandbox_bypass_detected }} + codeql_capability_contract: + value: ${{ jobs.analyze.outputs.codeql_capability_contract }} + codeql_capability_contract_detected: + value: ${{ jobs.analyze.outputs.codeql_capability_contract_detected }} + +permissions: + actions: read + contents: read + security-events: write + pull-requests: write + issues: write + +env: + PYTHONPATH: ${{ github.workspace }}/_trusted/.github/pluginctl/src + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + codeql_status: ${{ steps.status.outputs.codeql_status }} + codeql_errors: ${{ steps.status.outputs.codeql_errors }} + codeql_warnings: ${{ steps.status.outputs.codeql_warnings }} + codeql_mediums: ${{ steps.status.outputs.codeql_mediums }} + codeql_lows: ${{ steps.status.outputs.codeql_lows }} + codeql_unscanned_langs: ${{ steps.status.outputs.codeql_unscanned_langs }} + codeql_suppressed: ${{ steps.status.outputs.codeql_suppressed }} + codeql_sandbox_bypass: ${{ steps.status.outputs.codeql_sandbox_bypass }} + codeql_sandbox_bypass_detected: ${{ steps.status.outputs.codeql_sandbox_bypass_detected }} + codeql_capability_contract: ${{ steps.status.outputs.codeql_capability_contract }} + codeql_capability_contract_detected: ${{ steps.status.outputs.codeql_capability_contract_detected }} + steps: + - name: Checkout PR merge commit for analysis + uses: actions/checkout@v6 + with: + ref: refs/pull/${{ inputs.pr_number }}/merge + sparse-checkout: .gitignore + sparse-checkout-cone-mode: false + # Merge ref is analyzed statically by CodeQL only; fork code is not executed. + allow-unsafe-pr-checkout: true + + - name: Checkout trusted pluginctl (base) + uses: actions/checkout@v6 + with: + repository: ${{ github.repository }} + ref: ${{ inputs.base_ref }} + path: _trusted + sparse-checkout: | + .github/pluginctl + .github/codeql + sparse-checkout-cone-mode: false + + - name: Limit checkout to changed plugin folders only + run: | + echo '${{ inputs.matrix }}' | jq -r '.[] | "plugins/\(.)"' | git sparse-checkout set --stdin + git checkout + + - name: Populate external plugin source for analysis + run: | + while IFS= read -r plugin_name; do + plugin_json="plugins/$plugin_name/plugin.json" + [[ ! -f "$plugin_json" ]] && continue + source_type=$(jq -r '.source_type // "local"' "$plugin_json") + [[ "$source_type" != "external" ]] && continue + version=$(jq -r '.version' "$plugin_json") + source_url_template=$(jq -r '.source_url // ""' "$plugin_json") + source_url="${source_url_template//\{version\}/$version}" + echo "Fetching release ZIP for CodeQL: $source_url" + curl -fsSL "$source_url" -o "/tmp/${plugin_name}-release.zip" + mkdir -p "/tmp/${plugin_name}-src" + python3 -c "import zipfile,os; z=zipfile.ZipFile('/tmp/${plugin_name}-release.zip'); d='/tmp/${plugin_name}-src'; [z.extract((setattr(m,'filename',m.filename.replace(chr(92),'/')) or m),d) for m in z.infolist()]" + # Keep the release's runtime manifest separate from this repository's + # catalog plugin.json, which must not be used as capability policy. + if [[ -f "/tmp/${plugin_name}-src/plugin.json" ]]; then + cp "/tmp/${plugin_name}-src/plugin.json" "plugins/$plugin_name/.dispatcharr-runtime-manifest.json" + fi + cp -rn "/tmp/${plugin_name}-src/." "plugins/$plugin_name/" + rm -rf "/tmp/${plugin_name}-release.zip" "/tmp/${plugin_name}-src" + done < <(echo '${{ inputs.matrix }}' | jq -r '.[]') + + - name: Detect supported languages + id: detect-langs + run: python -m pluginctl detect-langs + + - name: Expose trusted CodeQL pack + if: steps.detect-langs.outputs.found == 'true' + run: | + mkdir -p .github + cp -R _trusted/.github/codeql .github/codeql + + - name: Initialize CodeQL + if: steps.detect-langs.outputs.found == 'true' + uses: github/codeql-action/init@v4 + with: + languages: ${{ steps.detect-langs.outputs.languages }} + build-mode: none + # Query suite (security-extended: all security severities, no quality queries) and + # per-language alert-suppression packs (so inline `codeql[]` comments work) + # both live in this config file - see .github/codeql/codeql-config.yml for why. Sourced + # from the trusted base-branch checkout, never the untrusted PR merge tree. + config-file: _trusted/.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + if: steps.detect-langs.outputs.found == 'true' + id: analyze + uses: github/codeql-action/analyze@v4 + with: + category: pr-${{ inputs.pr_number }} + output: sarif-results + upload: false + continue-on-error: true + + - name: Parse SARIF and set CodeQL status + id: status + if: always() + env: + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + python -m pluginctl sarif \ + --results-dir sarif-results \ + --repo "${{ github.repository }}" \ + --sha "$(git rev-parse HEAD)" \ + --matrix '${{ inputs.matrix }}' \ + --analyze-outcome "${{ steps.analyze.outcome }}" \ + --found "${{ steps.detect-langs.outputs.found }}" \ + --languages "${{ steps.detect-langs.outputs.languages }}" \ + --unscanned-langs "${{ steps.detect-langs.outputs.unscanned_langs }}" + + - name: Upload findings detail for PR comment + if: always() && steps.status.outputs.codeql_status == 'failure' + uses: actions/upload-artifact@v7 + with: + name: codeql-findings + path: codeql-findings.md + if-no-files-found: ignore + + - name: Upload medium findings detail for PR comment + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-medium-findings + path: codeql-medium-findings.md + if-no-files-found: ignore + + - name: Upload low findings detail for PR comment + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-low-findings + path: codeql-low-findings.md + if-no-files-found: ignore + + - name: Upload suppressed findings detail for PR comment + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-suppressed-findings + path: codeql-suppressed-findings.md + if-no-files-found: ignore + + - name: Upload sandbox-bypass findings detail for PR comment + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-sandbox-findings + path: codeql-sandbox-findings.md + if-no-files-found: ignore + + - name: Upload capability-contract findings detail for PR comment + if: always() + uses: actions/upload-artifact@v7 + with: + name: codeql-capability-contract-findings + path: codeql-capability-contract-findings.md + if-no-files-found: ignore + + - name: Fail job if CodeQL found high/error/critical issues + if: always() && steps.status.outputs.codeql_status == 'failure' + run: exit 1 diff --git a/.github/workflows/auto-merge-updates.yml b/.github/workflows/auto-merge-updates.yml index 3329c1ee..dd48eca5 100644 --- a/.github/workflows/auto-merge-updates.yml +++ b/.github/workflows/auto-merge-updates.yml @@ -1,18 +1,22 @@ name: Auto-merge Plugin Updates -# Opt-in via the AUTO_MERGE_UPDATES repo variable. When enabled, squash-merges -# a PR automatically once "Validate Plugin" succeeds - but ONLY for pure -# plugin version-bump updates. Never for new plugins, repo/tooling changes, -# invalid PRs, or PRs that mix a new plugin with an update. +# After every successful validation, this workflow either squash-merges an +# eligible pure plugin version-bump update or marks the PR for manual review. +# AUTO_MERGE_UPDATES controls merging, not the review decision. New plugins, +# repo/tooling changes, invalid PRs, and mixed changes require review. +# AUTO_MERGE_BLACKLIST is an optional comma-separated list of authors excluded +# only from automatic merges. +# Rate limits default to four author and plugin merges per rolling 24 hours; +# override with AUTO_MERGE_AUTHOR_LIMIT, AUTO_MERGE_PLUGIN_LIMIT, and +# AUTO_MERGE_RATE_WINDOW_HOURS. # -# Runs the merge with the GitHub App token specifically (never GITHUB_TOKEN): -# pushes authored by the App's bot identity correctly cascade into -# publish-plugins.yml's `on: push` trigger, whereas GITHUB_TOKEN-authored -# pushes are exempt from triggering other workflows. If the App isn't -# configured, auto-merge is skipped entirely rather than falling back. +# Merges require the GitHub App token because its pushes cascade into the publish +# workflow. Without it, the PR is marked for review instead of falling back to +# GITHUB_TOKEN. env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONPATH: .github/pluginctl/src on: workflow_run: @@ -22,161 +26,54 @@ on: permissions: contents: read - pull-requests: read + pull-requests: write jobs: auto-merge: - if: github.event.workflow_run.conclusion == 'success' && vars.AUTO_MERGE_UPDATES == 'true' + if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest timeout-minutes: 5 concurrency: - group: auto-merge-updates-${{ github.event.workflow_run.head_sha }} + # Serialize candidates so each rate-limit check sees earlier merges. + group: auto-merge-updates-${{ github.repository }} cancel-in-progress: false steps: - - name: Checkout base branch scripts (trusted) + - name: Checkout base branch (trusted) uses: actions/checkout@v6 with: repository: ${{ github.repository }} ref: main 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: Skip - GitHub App not configured - if: steps.config.outputs.use_app != 'true' - run: | - echo "::notice::GH_APP_ID or GH_APP_PRIVATE_KEY not configured. Skipping auto-merge (merging with GITHUB_TOKEN would not trigger publish-plugins.yml)." - - - name: Generate GitHub App token - if: steps.config.outputs.use_app == 'true' - id: app-token - uses: actions/create-github-app-token@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Resolve PR for this workflow run - if: steps.config.outputs.use_app == 'true' - id: resolve-pr + - name: Decide auto-merge or request review env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.token.outputs.token }} GH_REPO: ${{ github.repository }} + GITHUB_REPOSITORY: ${{ github.repository }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} + DISCORD_WEBHOOKS: ${{ secrets.DISCORD_WEBHOOKS }} + AUTO_MERGE_BLACKLIST: ${{ vars.AUTO_MERGE_BLACKLIST || '' }} + AUTO_MERGE_AUTHOR_LIMIT: ${{ vars.AUTO_MERGE_AUTHOR_LIMIT || '' }} + AUTO_MERGE_PLUGIN_LIMIT: ${{ vars.AUTO_MERGE_PLUGIN_LIMIT || '' }} + AUTO_MERGE_RATE_WINDOW_HOURS: ${{ vars.AUTO_MERGE_RATE_WINDOW_HOURS || '' }} + AUTO_MERGE_ENABLED: ${{ vars.AUTO_MERGE_UPDATES == 'true' }} + USE_APP: ${{ steps.token.outputs.use_app }} run: | - # workflow_run.pull_requests is unreliable for pull_request_target runs - # from forks, so match on head SHA against open PRs instead. - MATCHES=$(gh pr list --state open --json number,headRefOid,isDraft \ - | jq -c --arg sha "$HEAD_SHA" '[.[] | select(.headRefOid == $sha)]') - COUNT=$(echo "$MATCHES" | jq 'length') - - if [[ "$COUNT" -ne 1 ]]; then - echo "::notice::Found $COUNT open PR(s) matching head SHA $HEAD_SHA - skipping (expected exactly 1)." - echo "found=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - PR_NUMBER=$(echo "$MATCHES" | jq -r '.[0].number') - IS_DRAFT=$(echo "$MATCHES" | jq -r '.[0].isDraft') - - if [[ "$IS_DRAFT" == "true" ]]; then - echo "::notice::PR #$PR_NUMBER is a draft - skipping." - echo "found=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "found=true" >> "$GITHUB_OUTPUT" - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - - - name: Check labels and mergeable state - if: steps.config.outputs.use_app == 'true' && steps.resolve-pr.outputs.found == 'true' - id: check-labels - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ steps.resolve-pr.outputs.pr_number }} - run: | - INFO=$(gh pr view "$PR_NUMBER" --json labels,mergeable,mergeStateStatus) - LABELS=$(echo "$INFO" | jq -r '[.labels[].name] | join(",")') - MERGEABLE=$(echo "$INFO" | jq -r '.mergeable') - - echo "Labels: $LABELS" - echo "Mergeable: $MERGEABLE" - - has_label() { - echo ",$LABELS," | grep -qF ",$1," - } - - OK=true - has_label "Plugin Update" || { echo "::notice::Missing 'Plugin Update' label - skipping."; OK=false; } - has_label "New Plugin" && { echo "::notice::Has 'New Plugin' label - skipping."; OK=false; } - has_label "Repo Update" && { echo "::notice::Has 'Repo Update' label - skipping."; OK=false; } - has_label "Invalid" && { echo "::notice::Has 'Invalid' label - skipping."; OK=false; } - has_label "QUARANTINE" && { echo "::notice::Has 'QUARANTINE' label - skipping."; OK=false; } - has_label "CodeQL Suppression Used" && { echo "::notice::PR uses a CodeQL suppression comment - skipping auto-merge, requires maintainer review."; OK=false; } - [[ "$MERGEABLE" == "MERGEABLE" ]] || { echo "::notice::PR not cleanly mergeable (mergeable=$MERGEABLE) - skipping."; OK=false; } - - echo "ok=$OK" >> "$GITHUB_OUTPUT" - - - name: Independently re-verify this is a pure plugin update - if: steps.config.outputs.use_app == 'true' && steps.resolve-pr.outputs.found == 'true' && steps.check-labels.outputs.ok == 'true' - id: reverify - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ steps.resolve-pr.outputs.pr_number }} - run: | - # Defense in depth: labels are applied by a trusted job, but they are - # ordinary PR labels and could in principle be hand-edited by anyone - # with write access. Re-derive the same signal directly from the diff - # rather than trusting labels alone. - git fetch origin "pull/${PR_NUMBER}/merge:pr-merge-${PR_NUMBER}" 2>/dev/null \ - || { echo "::notice::Could not fetch merge ref for PR #$PR_NUMBER - skipping."; echo "ok=false" >> "$GITHUB_OUTPUT"; exit 0; } - git checkout -q "pr-merge-${PR_NUMBER}" - - MERGE_BASE=$(git merge-base origin/main HEAD) - CHANGED=$(git diff --name-only "$MERGE_BASE" HEAD) - - OK=true - - OUTSIDE=$(echo "$CHANGED" | grep -v '^plugins/' || true) - if [[ -n "$OUTSIDE" ]]; then - echo "::notice::Changes outside plugins/ detected - skipping. Files: $OUTSIDE" - OK=false - fi - - PLUGIN_SLUGS=$(echo "$CHANGED" | grep '^plugins/' | cut -d'/' -f2 | sort -u) - if [[ -z "$PLUGIN_SLUGS" ]]; then - echo "::notice::No plugin changes detected in diff - skipping." - OK=false + if [[ "$AUTO_MERGE_ENABLED" != "true" ]]; then + python -m pluginctl automerge --head-sha "$HEAD_SHA" \ + --disabled-reason "Automatic merging is disabled for this repository." + elif [[ "$USE_APP" != "true" ]]; then + python -m pluginctl automerge --head-sha "$HEAD_SHA" \ + --disabled-reason "GitHub App credentials are not configured for automatic merging." + else + python -m pluginctl automerge --head-sha "$HEAD_SHA" fi - - while IFS= read -r slug; do - [[ -z "$slug" ]] && continue - if ! git show "origin/main:plugins/${slug}/plugin.json" > /dev/null 2>&1; then - echo "::notice::Plugin '$slug' does not exist on main - this is a new plugin, not an update. Skipping." - OK=false - fi - done <<< "$PLUGIN_SLUGS" - - echo "ok=$OK" >> "$GITHUB_OUTPUT" - - - name: Squash-merge PR - if: steps.config.outputs.use_app == 'true' && steps.resolve-pr.outputs.found == 'true' && steps.check-labels.outputs.ok == 'true' && steps.reverify.outputs.ok == 'true' - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ steps.resolve-pr.outputs.pr_number }} - run: | - gh pr merge "$PR_NUMBER" --squash --delete-branch=false - echo "Auto-merged PR #$PR_NUMBER" diff --git a/.github/workflows/codeql-query-tests.yml b/.github/workflows/codeql-query-tests.yml new file mode 100644 index 00000000..4905139b --- /dev/null +++ b/.github/workflows/codeql-query-tests.yml @@ -0,0 +1,33 @@ +name: CodeQL Query Tests + +on: + pull_request: + paths: + - .github/codeql/queries/** + push: + branches: [main] + paths: + - .github/codeql/queries/** + +permissions: + contents: read + security-events: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - id: init + uses: github/codeql-action/init@v4 + with: + languages: python + - name: Install query test dependencies + run: ${{ steps.init.outputs.codeql-path }} pack install --additional-packs=.. + working-directory: .github/codeql/queries/python/tests + - name: Run query tests + run: | + ! rg -n '^ERROR:' .github/codeql/queries/python/tests --glob '*.expected' + ${{ steps.init.outputs.codeql-path }} query compile --check-only .github/codeql/queries/python + ${{ steps.init.outputs.codeql-path }} test run --additional-packs=.github/codeql/queries/python .github/codeql/queries/python/tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 0b037181..00000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: CodeQL - -# Pull request analysis is handled by validate-plugin.yml (codeql-analyze job), -# which runs CodeQL as part of the plugin validation flow. -on: - workflow_dispatch: - # push: - # branches: [main] - # schedule: - # - cron: '0 6 * * 1' - -jobs: - analyze: - name: Analyze (python) - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: python - queries: security-and-quality - - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: /language:python \ No newline at end of file diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index dce61b22..a036f7b7 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -5,6 +5,7 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONPATH: .github/pluginctl/src on: push: @@ -13,12 +14,7 @@ on: paths: - 'plugins/**' - '.github/workflows/publish-plugins.yml' - - '.github/scripts/publish/run.sh' - - '.github/scripts/publish/build-zips.sh' - - '.github/scripts/publish/plugin-readmes.sh' - - '.github/scripts/publish/cleanup.sh' - - '.github/scripts/publish/generate-manifest.sh' - - '.github/scripts/publish/releases-readme.sh' + - '.github/pluginctl/**' - '.github/scripts/keys/**' workflow_dispatch: inputs: @@ -28,7 +24,7 @@ on: default: false required: false force_rebuild_plugin: - description: 'Optional: plugin slug to target (e.g. dispatcharr-exporter). Only that plugin is cleared and rebuilt - all others are untouched. Leave blank to wipe and rebuild the entire releases branch (WARNING: purges all version history).' + description: 'Optional: plugin slug to target (e.g. dispatcharr-exporter). Only that plugin is cleared and rebuilt, all others are untouched. Leave blank to wipe and rebuild the entire releases branch (WARNING: purges all version history).' type: string default: '' required: false @@ -52,53 +48,34 @@ 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@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + APP_SLUG: ${{ steps.token.outputs.app-slug }} GITHUB_REPOSITORY: ${{ github.repository }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} FORCE_REBUILD: ${{ inputs.force_rebuild }} FORCE_REBUILD_CONFIRM: ${{ inputs.force_rebuild_confirm }} FORCE_REBUILD_PLUGIN: ${{ inputs.force_rebuild_plugin }} + WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} + DISCORD_WEBHOOKS: ${{ secrets.DISCORD_WEBHOOKS }} 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 + python -m pluginctl publish ${{ github.ref_name }} 2>&1 | tee publish.log - name: Generate job summary if: always() @@ -107,17 +84,9 @@ jobs: if [ -f publish.log ]; then if grep -q "Successfully published" publish.log; then echo "✅ Publishing completed successfully" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - # Extract plugin info from the temporary repo if available - if [ -d /tmp/tmp.*/repo ]; then - REPO_DIR=$(find /tmp -maxdepth 1 -name "tmp.*" -type d 2>/dev/null | head -1)/repo - if [ -f "$REPO_DIR/manifest.json" ]; then - cat "$REPO_DIR/manifest.json" | jq -r '.manifest.plugins[] | "- **\(.name)** v\(.latest_version)"' >> $GITHUB_STEP_SUMMARY - fi - fi else echo "❌ Publishing failed" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "See logs for details." >> $GITHUB_STEP_SUMMARY fi - fi \ No newline at end of file + fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..449774a7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,67 @@ +name: Tests + +# Runs pluginctl and custom CodeQL query tests. Dual purpose: +# - on push to main for any change outside plugins/ (tooling CI) +# - via workflow_call from validate-plugin.yml, so a repo-update PR's result +# feeds the Plugin PR Check gate and the PR comment +# Runs with a read-only token and no secrets, so testing PR merge-ref code is +# low-risk. + +on: + push: + branches: [main] + paths-ignore: + - 'plugins/**' + workflow_dispatch: + workflow_call: + inputs: + ref: + description: Git ref to test (empty checks out the default for the event). + type: string + required: false + default: '' + +permissions: + contents: read + +jobs: + pytest: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + sparse-checkout: | + .github/codeql + .github/pluginctl + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install pytest + run: python -m pip install --quiet pytest + + - name: Run test suite + working-directory: .github/pluginctl + run: python -m pytest -q + + - name: Initialize CodeQL + id: codeql + uses: github/codeql-action/init@v4 + with: + languages: python + + - name: Install CodeQL query test dependencies + run: ${{ steps.codeql.outputs.codeql-path }} pack install --additional-packs=.. + working-directory: .github/codeql/queries/python/tests + + - name: Run CodeQL query tests + run: | + ${{ steps.codeql.outputs.codeql-path }} query compile --check-only .github/codeql/queries/python + ${{ steps.codeql.outputs.codeql-path }} test run --additional-packs=.github/codeql/queries/python .github/codeql/queries/python/tests diff --git a/.github/workflows/update-external-readme.yml b/.github/workflows/update-external-readme.yml index 83417701..7a22aba3 100644 --- a/.github/workflows/update-external-readme.yml +++ b/.github/workflows/update-external-readme.yml @@ -3,6 +3,9 @@ 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. +env: + PYTHONPATH: .github/pluginctl/src + on: workflow_run: workflows: ["Publish Plugins"] @@ -17,9 +20,8 @@ jobs: 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 + - name: Validate configuration id: config env: GH_APP_ID: ${{ vars.GH_APP_ID }} @@ -31,176 +33,37 @@ jobs: 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" + echo "skip=false" >> "$GITHUB_OUTPUT" fi - - name: Generate GitHub App token - if: steps.config.outputs.skip != 'true' - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-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 + # Keep the base branch checked out: the composite action and pluginctl live + # here. The releases-branch README is read via the origin/releases ref below. + - name: Checkout base branch (trusted) if: steps.config.outputs.skip != 'true' uses: actions/checkout@v6 with: - ref: releases fetch-depth: 1 - clean: false - sparse-checkout: README.md - sparse-checkout-cone-mode: false - - name: Extract release metadata from commit + - name: GitHub App token 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 + id: token + uses: ./.github/actions/gh-app-token + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} - - name: Strip releases-branch preamble from README + - name: Fetch releases branch 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 + run: git fetch origin releases - - name: Create or update PR in target repo + - name: Create or update external README PR 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 }} + GH_TOKEN: ${{ steps.token.outputs.token }} + TARGET_REPO: ${{ vars.EXTERNAL_README_REPO }} + TARGET_PATH: ${{ vars.EXTERNAL_README_PATH }} SOURCE_REPO: ${{ github.repository }} - SOURCE_COMMIT: ${{ steps.meta.outputs.source_commit }} - RELEASES_COMMIT: ${{ steps.meta.outputs.releases_commit }} + GITHUB_REPOSITORY: ${{ github.repository }} 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 + run: python -m pluginctl external-readme diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index 4d0a977c..1fc8adaa 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -1,6 +1,7 @@ name: Validate Plugin permissions: + actions: read contents: read pull-requests: write issues: write @@ -8,10 +9,11 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONPATH: .github/pluginctl/src # Comma-separated lists of blocked GitHub usernames and plugin slugs. - # Set these in repo Settings → Variables as AUTHOR_BLACKLIST and PLUGIN_BLACKLIST. - AUTHOR_BLACKLIST: ${{ vars.AUTHOR_BLACKLIST }} - PLUGIN_BLACKLIST: ${{ vars.PLUGIN_BLACKLIST }} + # Set these in repo Settings -> Variables as AUTHOR_BLACKLIST and PLUGIN_BLACKLIST. + AUTHOR_BLACKLIST: ${{ vars.AUTHOR_BLACKLIST || '' }} + PLUGIN_BLACKLIST: ${{ vars.PLUGIN_BLACKLIST || '' }} on: pull_request_target: @@ -27,7 +29,7 @@ concurrency: jobs: # -------------------------------------------------------------------------- - # Job 1: Post a notice on draft PRs - no validation runs yet + # Job 1: Post a notice on draft PRs, no validation runs yet # -------------------------------------------------------------------------- draft-notice: if: github.event.pull_request.draft == true @@ -39,40 +41,21 @@ jobs: with: ref: ${{ github.event.pull_request.base.ref }} fetch-depth: 1 - sparse-checkout: .github/scripts + sparse-checkout: .github 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@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: ${{ 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_TOKEN: ${{ steps.token.outputs.token }} + BOT_LOGIN: ${{ steps.token.outputs.bot-login }} GH_REPO: ${{ github.repository }} run: | MARKER="" @@ -86,153 +69,95 @@ jobs: fi # -------------------------------------------------------------------------- - # Job 2: Detect which plugins changed and build the matrix + # Job 2: Reconcile labels before any PR automation uses them + # -------------------------------------------------------------------------- + ensure-labels: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Checkout config + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + sparse-checkout: .github + sparse-checkout-cone-mode: false + + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Ensure automation labels + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + GH_REPO: ${{ github.repository }} + run: python -m pluginctl ensure-labels + + # -------------------------------------------------------------------------- + # Job 3: Detect which plugins changed and build the matrix (+ blacklist) # -------------------------------------------------------------------------- detect-changes: + needs: [ensure-labels] if: github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 5 outputs: matrix: ${{ steps.detect.outputs.matrix }} plugin_count: ${{ steps.detect.outputs.plugin_count }} - close_pr: ${{ steps.blacklist.outputs.close_pr || steps.detect.outputs.close_pr }} - close_reason: ${{ steps.blacklist.outputs.close_reason || steps.detect.outputs.close_reason }} + close_pr: ${{ steps.detect.outputs.close_pr }} + close_reason: ${{ steps.detect.outputs.close_reason }} 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 }} has_new_plugin: ${{ steps.detect.outputs.has_new_plugin }} has_updated_plugin: ${{ steps.detect.outputs.has_updated_plugin }} + metadata_only: ${{ steps.detect.outputs.metadata_only }} steps: - - name: Checkout base branch scripts (trusted) + # A local composite action must exist in the workspace before `uses:` can load it. + - name: Checkout trusted action uses: actions/checkout@v6 with: - repository: ${{ github.repository }} ref: ${{ github.event.pull_request.base.ref }} - fetch-depth: 0 - sparse-checkout: .github/scripts + fetch-depth: 1 + sparse-checkout: .github/actions/trusted-checkout sparse-checkout-cone-mode: false - - name: Fetch base branch - run: git fetch origin ${{ github.event.pull_request.base.ref }} - - - name: Save trusted scripts before fork checkout - run: cp -r .github/scripts /tmp/trusted-scripts - - - name: Checkout PR plugins (untrusted content only) - uses: actions/checkout@v6 + - name: Trusted checkout + uses: ./.github/actions/trusted-checkout with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - sparse-checkout: plugins - sparse-checkout-cone-mode: false - clean: false - # Fork content is only read as data (sparse plugins/), never executed; - # trusted scripts are saved/restored around this step. - allow-unsafe-pr-checkout: true - - - name: Restore trusted scripts - run: mkdir -p .github && cp -r /tmp/trusted-scripts .github/scripts - - - 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 + base-ref: ${{ github.event.pull_request.base.ref }} + head-repo: ${{ github.event.pull_request.head.repo.full_name }} + head-sha: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.repository }} - - name: Generate GitHub App token - if: steps.config.outputs.use_app == 'true' - id: app-token - uses: actions/create-github-app-token@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + GH_TOKEN: ${{ steps.token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} - run: | - chmod +x .github/scripts/validate/*.sh - .github/scripts/validate/detect-changes.sh \ - "${{ github.event.pull_request.user.login }}" \ - "${{ github.event.pull_request.base.ref }}" \ - "${{ github.event.pull_request.head.ref }}" - - - name: Check author and plugin blacklists - id: blacklist - # Only run if detect didn't already decide to close (avoids redundant output) - if: steps.detect.outputs.close_pr != 'true' - env: PR_AUTHOR: ${{ github.event.pull_request.user.login }} - MATRIX: ${{ steps.detect.outputs.matrix }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | - AUTHOR_BL="${AUTHOR_BLACKLIST:-}" - PLUGIN_BL="${PLUGIN_BLACKLIST:-}" - - # No-op if neither list is configured - if [[ -z "$AUTHOR_BL" && -z "$PLUGIN_BL" ]]; then - exit 0 - fi - - MATCHED=false - REASON="" - - # Check author blacklist (case-insensitive, strips whitespace around commas) - if [[ -n "$AUTHOR_BL" ]]; then - IFS=',' read -ra BLOCKED <<< "$AUTHOR_BL" - for entry in "${BLOCKED[@]}"; do - slug="${entry// /}" - if [[ "${PR_AUTHOR,,}" == "${slug,,}" ]]; then - MATCHED=true - REASON="author-blacklisted" - echo "::warning::PR author '$PR_AUTHOR' is on the author blacklist." - break - fi - done - fi - - # Check plugin blacklist (case-insensitive) - if [[ "$MATCHED" != "true" && -n "$PLUGIN_BL" ]]; then - IFS=',' read -ra BLOCKED <<< "$PLUGIN_BL" - while IFS= read -r plugin; do - [[ -z "$plugin" ]] && continue - for entry in "${BLOCKED[@]}"; do - slug="${entry// /}" - if [[ "${plugin,,}" == "${slug,,}" ]]; then - MATCHED=true - REASON="plugin-blacklisted" - echo "::warning::Plugin '$plugin' is on the plugin blacklist." - break 2 - fi - done - done < <(printf '%s' "$MATRIX" | jq -r '.[]' 2>/dev/null || true) - fi - - if [[ "$MATCHED" == "true" ]]; then - echo "close_pr=true" >> "$GITHUB_OUTPUT" - echo "close_reason=$REASON" >> "$GITHUB_OUTPUT" - fi + python -m pluginctl detect \ + --author "$PR_AUTHOR" \ + --base-ref "$BASE_REF" \ + --head-ref "$HEAD_REF" # -------------------------------------------------------------------------- - # Job 3: Apply PR labels based on change classification + # Job 4: Apply PR labels based on change classification # -------------------------------------------------------------------------- label-pr: needs: [detect-changes] @@ -245,75 +170,31 @@ jobs: with: ref: ${{ github.event.pull_request.base.ref }} fetch-depth: 1 - sparse-checkout: .github/scripts + sparse-checkout: .github 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@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + GH_TOKEN: ${{ steps.token.outputs.token }} GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HAS_NEW_PLUGIN: ${{ needs.detect-changes.outputs.has_new_plugin }} - HAS_UPDATED_PLUGIN: ${{ needs.detect-changes.outputs.has_updated_plugin }} - OUTSIDE_FILES: ${{ needs.detect-changes.outputs.outside_files }} - OUTSIDE_VIOLATION: ${{ needs.detect-changes.outputs.outside_violation }} - CLOSE_PR: ${{ needs.detect-changes.outputs.close_pr }} run: | - apply_label() { - local label=$1 condition=$2 - if [[ "$condition" == "true" ]]; then - gh pr edit "$PR_NUMBER" --add-label "$label" || true - else - gh pr edit "$PR_NUMBER" --remove-label "$label" 2>/dev/null || true - fi - } - apply_label "New Plugin" "$HAS_NEW_PLUGIN" - apply_label "Plugin Update" "$HAS_UPDATED_PLUGIN" - # Repo Update only when there are outside files AND it's not a violation (authorized) - if [[ -n "$OUTSIDE_FILES" && "$OUTSIDE_VIOLATION" != "true" ]]; then - IS_REPO_UPDATE=true - else - IS_REPO_UPDATE=false - fi - apply_label "Repo Update" "$IS_REPO_UPDATE" - # Invalid when PR has unauthorized outside changes or unauthorized plugin modifications - if [[ "$OUTSIDE_VIOLATION" == "true" || "$CLOSE_PR" == "true" ]]; then - IS_INVALID=true - else - IS_INVALID=false - fi - apply_label "Invalid" "$IS_INVALID" + python -m pluginctl label \ + --pr "${{ github.event.pull_request.number }}" \ + --has-new-plugin "${{ needs.detect-changes.outputs.has_new_plugin }}" \ + --has-updated-plugin "${{ needs.detect-changes.outputs.has_updated_plugin }}" \ + --outside-files "${{ needs.detect-changes.outputs.outside_files }}" \ + --outside-violation "${{ needs.detect-changes.outputs.outside_violation }}" \ + --close-pr "${{ needs.detect-changes.outputs.close_pr }}" # -------------------------------------------------------------------------- - # Job 4: Validate PR title format - # Runs on every non-draft, non-closing PR event (including 'edited', which - # fires when the title is renamed). + # Job 5: Validate PR title format # -------------------------------------------------------------------------- validate-title: needs: [detect-changes] @@ -325,796 +206,66 @@ jobs: title_feedback: ${{ steps.check.outputs.title_feedback }} title_suggestion: ${{ steps.check.outputs.title_suggestion }} steps: + - name: Checkout config + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + sparse-checkout: .github/pluginctl + sparse-checkout-cone-mode: false + - name: Validate PR title format id: check env: PR_TITLE: ${{ github.event.pull_request.title }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - PLUGIN_COUNT: ${{ needs.detect-changes.outputs.plugin_count }} - MATRIX: ${{ needs.detect-changes.outputs.matrix }} - SKIP_VALIDATION: ${{ needs.detect-changes.outputs.skip_validation }} run: | - TITLE="$PR_TITLE" - AUTHOR="$PR_AUTHOR" - COUNT="${PLUGIN_COUNT:-0}" - SKIP="${SKIP_VALIDATION:-false}" - MATRIX_JSON="${MATRIX:-[]}" - TITLE_VALID=true - TITLE_FEEDBACK="" - TITLE_SUGGESTION="" - - # Build a context-appropriate suggestion for this PR - if [[ "$COUNT" == "0" ]]; then - CONTEXT_SUGGESTION="[repo]: Brief description of changes" - elif [[ "$COUNT" == "1" ]]; then - SLUG=$(printf '%s' "$MATRIX_JSON" | jq -r '.[0] // "plugin-slug"' || echo "plugin-slug") - CONTEXT_SUGGESTION="[$SLUG]: Brief description of changes" - else - CONTEXT_SUGGESTION="[$AUTHOR]: Brief description of changes" - fi - - # Check format and extract prefix using bash =~ to avoid grep/sed ERE inconsistencies. - # [^]] inside bash =~ means "not ]" (] is literal when first after [^). - if [[ "$TITLE" =~ ^\[([^]]+)\]:?[[:space:]]+.+ ]]; then - PREFIX="${BASH_REMATCH[1]}" - - # Validate prefix matches expected context - if [[ "$COUNT" == "0" ]]; then - if [[ "$PREFIX" != "repo" ]]; then - TITLE_VALID=false - TITLE_FEEDBACK="For repo-level or non-plugin changes, the prefix should be \`[repo]\`." - TITLE_SUGGESTION="$CONTEXT_SUGGESTION" - fi - elif [[ "$COUNT" == "1" ]]; then - EXPECTED=$(printf '%s' "$MATRIX_JSON" | jq -r '.[0] // ""' || echo "") - if [[ -n "$EXPECTED" && "$PREFIX" != "$EXPECTED" ]]; then - TITLE_VALID=false - TITLE_FEEDBACK="For a single plugin change, the prefix should match the plugin folder name: \`[$EXPECTED]\`." - TITLE_SUGGESTION="$CONTEXT_SUGGESTION" - fi - else - if [[ "$PREFIX" != "$AUTHOR" ]]; then - TITLE_VALID=false - TITLE_FEEDBACK="For changes to multiple plugins, the prefix should be your GitHub username: \`[$AUTHOR]\`." - TITLE_SUGGESTION="$CONTEXT_SUGGESTION" - fi - fi - else - TITLE_VALID=false - TITLE_FEEDBACK="PR title does not match the required format. Expected: \`[prefix] description\`." - TITLE_SUGGESTION="$CONTEXT_SUGGESTION" - fi - - echo "title_valid=$TITLE_VALID" >> "$GITHUB_OUTPUT" - { - echo "title_feedback<> "$GITHUB_OUTPUT" - { - echo "title_suggestion<> "$GITHUB_OUTPUT" - - if [[ "$TITLE_VALID" != "true" ]]; then - echo "::error::PR title does not match the required format. See CONTRIBUTING.md for details." - exit 1 - fi + python -m pluginctl check-title \ + --title "$PR_TITLE" \ + --author "${{ github.event.pull_request.user.login }}" \ + --plugin-count "${{ needs.detect-changes.outputs.plugin_count }}" \ + --matrix '${{ needs.detect-changes.outputs.matrix }}' # -------------------------------------------------------------------------- - # Job 5: CodeQL security and quality analysis (runs after validate-plugin) + # Job 5: CodeQL security and quality analysis (reusable workflow) # -------------------------------------------------------------------------- codeql-analyze: needs: [detect-changes, validate-plugin, validate-title] - if: needs.validate-plugin.result == 'success' && needs.detect-changes.outputs.skip_validation != 'true' && needs.validate-title.outputs.title_valid == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - outputs: - codeql_status: ${{ steps.status.outputs.codeql_status }} - codeql_errors: ${{ steps.status.outputs.codeql_errors }} - codeql_warnings: ${{ steps.status.outputs.codeql_warnings }} - codeql_mediums: ${{ steps.status.outputs.codeql_mediums }} - codeql_lows: ${{ steps.status.outputs.codeql_lows }} - codeql_suppressed: ${{ steps.status.outputs.codeql_suppressed }} - codeql_unscanned_langs: ${{ steps.status.outputs.codeql_unscanned_langs }} - steps: - - name: Checkout PR merge commit for analysis - uses: actions/checkout@v6 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/merge - # Minimal placeholder - overridden immediately by the next step - # (actions/checkout doesn't support dynamic expressions in sparse-checkout) - sparse-checkout: .gitignore - sparse-checkout-cone-mode: false - # Merge ref is analyzed statically by CodeQL only; fork code is not executed. - allow-unsafe-pr-checkout: true - - - name: Limit checkout to changed plugin folders only - run: | - { - echo '.github/codeql' - echo '${{ needs.detect-changes.outputs.matrix }}' | jq -r '.[] | "plugins/\(.)"' - } | git sparse-checkout set --stdin - git checkout - - - name: Populate external plugin source for analysis - run: | - while IFS= read -r plugin_name; do - plugin_json="plugins/$plugin_name/plugin.json" - [[ ! -f "$plugin_json" ]] && continue - - source_type=$(jq -r '.source_type // "local"' "$plugin_json") - [[ "$source_type" != "external" ]] && continue - - version=$(jq -r '.version' "$plugin_json") - source_url_template=$(jq -r '.source_url // ""' "$plugin_json") - source_url="${source_url_template//\{version\}/$version}" - - echo "Fetching release ZIP for CodeQL: $source_url" - curl -fsSL "$source_url" -o "/tmp/${plugin_name}-release.zip" - - mkdir -p "/tmp/${plugin_name}-src" - python3 -c "import zipfile,os; z=zipfile.ZipFile('/tmp/${plugin_name}-release.zip'); d='/tmp/${plugin_name}-src'; [z.extract((setattr(m,'filename',m.filename.replace(chr(92),'/')) or m),d) for m in z.infolist()]" - # Copy extracted files into plugins dir without overwriting the registry plugin.json - cp -rn "/tmp/${plugin_name}-src/." "plugins/$plugin_name/" - rm -rf "/tmp/${plugin_name}-release.zip" "/tmp/${plugin_name}-src" - echo "Release ZIP for $plugin_name extracted into plugins/$plugin_name/" - done < <(echo '${{ needs.detect-changes.outputs.matrix }}' | jq -r '.[]') - - - name: Detect supported languages - id: detect-langs - run: | - LANGS=() - UNSCANNED=() - - # --- CodeQL-supported languages --- - if find plugins -name '*.py' -print -quit | grep -q .; then - LANGS+=(python) - fi - if find plugins \ - \( -path '*/node_modules/*' -o -path '*/dist/*' -o -path '*/build/*' -o -path '*/static/*' \) -prune -o \ - \( -name '*.js' -o -name '*.ts' -o -name '*.jsx' -o -name '*.tsx' \) -print \ - -quit | grep -q .; then - LANGS+=(javascript) - fi - if find plugins -name '*.go' -print -quit | grep -q .; then - LANGS+=(go) - fi - if find plugins -name '*.rb' -print -quit | grep -q .; then - LANGS+=(ruby) - fi - if find plugins \( -name '*.java' -o -name '*.kt' -o -name '*.kts' \) -print -quit | grep -q .; then - LANGS+=("java-kotlin") - fi - if find plugins \( -name '*.c' -o -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) -print -quit | grep -q .; then - LANGS+=("c-cpp") - fi - - # --- Files present but not supported by CodeQL --- - if find plugins \( -name '*.sh' -o -name '*.bash' \) -print -quit | grep -q .; then - UNSCANNED+=(shell) - fi - if find plugins -name '*.php' -print -quit | grep -q .; then - UNSCANNED+=(php) - fi - if find plugins -name '*.lua' -print -quit | grep -q .; then - UNSCANNED+=(lua) - fi - if find plugins \( -name '*.pl' -o -name '*.pm' \) -print -quit | grep -q .; then - UNSCANNED+=(perl) - fi - if find plugins -name '*.rs' -print -quit | grep -q .; then - UNSCANNED+=(rust) - fi - - UNSCANNED_CSV="" - [[ ${#UNSCANNED[@]} -gt 0 ]] && UNSCANNED_CSV=$(IFS=,; echo "${UNSCANNED[*]}") - echo "unscanned_langs=$UNSCANNED_CSV" >> "$GITHUB_OUTPUT" - - if [[ ${#LANGS[@]} -gt 0 ]]; then - LANG_CSV=$(IFS=,; echo "${LANGS[*]}") - echo "found=true" >> "$GITHUB_OUTPUT" - echo "languages=$LANG_CSV" >> "$GITHUB_OUTPUT" - echo "Detected languages for CodeQL: $LANG_CSV" - if [[ -n "$UNSCANNED_CSV" ]]; then echo "Unscanned (no CodeQL support): $UNSCANNED_CSV"; fi - else - echo "found=false" >> "$GITHUB_OUTPUT" - echo "languages=" >> "$GITHUB_OUTPUT" - echo "No supported language files found in changed plugins - skipping CodeQL." - if [[ -n "$UNSCANNED_CSV" ]]; then echo "Unscanned file types detected (no CodeQL support): $UNSCANNED_CSV"; fi - fi - - - name: Get merge commit SHA - id: merge - run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - - - name: Initialize CodeQL - if: steps.detect-langs.outputs.found == 'true' - uses: github/codeql-action/init@v4 - with: - languages: ${{ steps.detect-langs.outputs.languages }} - build-mode: none - # Query suite (security-extended: all security severities, no quality queries) and - # per-language alert-suppression packs (so inline `codeql[]` comments work) - # both live in this config file - see .github/codeql/codeql-config.yml for why. - config-file: .github/codeql/codeql-config.yml - - - name: Perform CodeQL Analysis - if: steps.detect-langs.outputs.found == 'true' - id: analyze - uses: github/codeql-action/analyze@v4 - with: - category: pr-${{ github.event.pull_request.number }} - output: sarif-results - upload: false - continue-on-error: true - - - name: Set CodeQL status output - id: status - if: always() - run: | - # Only block on security findings with CVSS score >= 7.0 (HIGH or CRITICAL). - # CodeQL stores this in rule properties["security-severity"], not in result.level. - # A result with a non-empty .suppressions array was recognized by CodeQL's own - # engine as validly suppressed (e.g. a correctly-placed inline `codeql[...]` - # comment) - trust that signal and never let it count as blocking/medium/low. - JQ_BLOCKING=' - [ .runs[] | - . as $run | - ( - [ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] - | from_entries - ) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - ((.ruleId // .rule.id // "") | tostring) as $rid | - select((($secmap[$rid] // "0") | tonumber) >= 7.0) - ] | length' - JQ_MEDIUM=' - [ .runs[] | - . as $run | - ( - [ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] - | from_entries - ) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - ((.ruleId // .rule.id // "") | tostring) as $rid | - (($secmap[$rid] // "0") | tonumber) as $sev | - select($sev >= 6.0 and $sev < 7.0) - ] | length' - JQ_LOW=' - [ .runs[] | - . as $run | - ( - [ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] - | from_entries - ) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - ((.ruleId // .rule.id // "") | tostring) as $rid | - (($secmap[$rid] // "0") | tonumber) as $sev | - select($sev < 6.0) - ] | length' - JQ_SUPPRESSED=' - [ .runs[] | (.results // [])[] | select((.suppressions // []) | length > 0) ] | length' - RESULT_COUNT=0 - MEDIUM_COUNT=0 - LOW_COUNT=0 - SUPPRESSED_COUNT=0 - TOTAL_COUNT=0 - if [[ -d "sarif-results" ]]; then - for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do - [[ -f "$f" ]] || continue - if [[ "$f" == *.gz ]]; then - CONTENT=$(gunzip -c "$f") - else - CONTENT=$(cat "$f") - fi - COUNT=$(echo "$CONTENT" | jq "$JQ_BLOCKING") - MED=$(echo "$CONTENT" | jq "$JQ_MEDIUM") - LOW=$(echo "$CONTENT" | jq "$JQ_LOW") - SUPPRESSED=$(echo "$CONTENT" | jq "$JQ_SUPPRESSED") - TOT=$(echo "$CONTENT" | jq '[.runs[] | (.results // [])[]] | length') - echo "File $f: ${COUNT:-0} blocking, ${MED:-0} medium, ${LOW:-0} low, ${SUPPRESSED:-0} suppressed, $TOT total" - echo "$CONTENT" | jq -r \ - '[ .runs[] | . as $run | - ([ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] | from_entries) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - ((.ruleId // .rule.id // "") | tostring) as $rid | - select((($secmap[$rid] // "0") | tonumber) >= 7.0) | - " [blocking] \($rid) sec-sev=\($secmap[$rid] // "n/a")" ] | .[]' || true - RESULT_COUNT=$((RESULT_COUNT + ${COUNT:-0})) - MEDIUM_COUNT=$((MEDIUM_COUNT + ${MED:-0})) - LOW_COUNT=$((LOW_COUNT + ${LOW:-0})) - SUPPRESSED_COUNT=$((SUPPRESSED_COUNT + ${SUPPRESSED:-0})) - TOTAL_COUNT=$((TOTAL_COUNT + ${TOT:-0})) - done - fi - WARN_COUNT=$(( TOTAL_COUNT > RESULT_COUNT ? TOTAL_COUNT - RESULT_COUNT : 0 )) - echo "Found $RESULT_COUNT high/critical, $MEDIUM_COUNT medium, $LOW_COUNT low, $SUPPRESSED_COUNT suppressed, and $WARN_COUNT other CodeQL result(s)" - echo "codeql_errors=$RESULT_COUNT" >> "$GITHUB_OUTPUT" - echo "codeql_warnings=$WARN_COUNT" >> "$GITHUB_OUTPUT" - echo "codeql_mediums=$MEDIUM_COUNT" >> "$GITHUB_OUTPUT" - echo "codeql_lows=$LOW_COUNT" >> "$GITHUB_OUTPUT" - echo "codeql_suppressed=$SUPPRESSED_COUNT" >> "$GITHUB_OUTPUT" - - # Build list of external plugin path prefixes so findings links are suppressed - # for files that came from a downloaded ZIP and don't exist in this repo. - EXTERNAL_PREFIXES='[]' - while IFS= read -r _plugin_name; do - _pjson="plugins/$_plugin_name/plugin.json" - [[ -f "$_pjson" ]] || continue - _stype=$(jq -r '.source_type // "local"' "$_pjson" 2>/dev/null || echo "local") - if [[ "$_stype" == "external" ]]; then - EXTERNAL_PREFIXES=$(printf '%s' "$EXTERNAL_PREFIXES" | jq --arg p "plugins/$_plugin_name/" '. + [$p]') - fi - done < <(echo '${{ needs.detect-changes.outputs.matrix }}' | jq -r '.[]') - - # Generate a findings detail file for inclusion in the PR comment - if [[ "$RESULT_COUNT" -gt 0 ]]; then - MERGE_SHA=$(git rev-parse HEAD) - { - echo "| Rule | Location | Description |" - echo "|------|----------|-------------|" - for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do - [[ -f "$f" ]] || continue - if [[ "$f" == *.gz ]]; then - FC=$(gunzip -c "$f") - else - FC=$(cat "$f") - fi - echo "$FC" | jq -r \ - --arg repo "$GITHUB_REPOSITORY" \ - --arg sha "$MERGE_SHA" \ - --argjson external_prefixes "$EXTERNAL_PREFIXES" \ - '.runs[] | - . as $run | - ( - [ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] - | from_entries - ) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - . as $result | - (($result.ruleId // $result.rule.id // "") | tostring) as $rid | - select((($secmap[$rid] // "0") | tonumber) >= 7.0) | - (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | - ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") - # Data-flow queries can repeat the same sentence once per source/flow reaching - # the same sink - collapse repeats while preserving first-seen order. - | [splits("(?<=[.!?]) ")] as $sentences - | reduce $sentences[] as $s ([]; if any(.[]; . == $s) then . else . + [$s] end) - | join(" ")) as $msg | - ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | - (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | - "| `\($rid)` | \($loc) | \($msg) |"' - done - } > codeql-findings.md - fi - # Generate medium findings detail file for informational display in the PR comment - if [[ "$MEDIUM_COUNT" -gt 0 ]]; then - MERGE_SHA=${MERGE_SHA:-$(git rev-parse HEAD)} - { - echo "| Rule | Location | Description |" - echo "|------|----------|-------------|" - for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do - [[ -f "$f" ]] || continue - if [[ "$f" == *.gz ]]; then - FC=$(gunzip -c "$f") - else - FC=$(cat "$f") - fi - echo "$FC" | jq -r \ - --arg repo "$GITHUB_REPOSITORY" \ - --arg sha "$MERGE_SHA" \ - --argjson external_prefixes "$EXTERNAL_PREFIXES" \ - '.runs[] | - . as $run | - ( - [ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] - | from_entries - ) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - . as $result | - (($result.ruleId // $result.rule.id // "") | tostring) as $rid | - (($secmap[$rid] // "0") | tonumber) as $sev | - select($sev >= 6.0 and $sev < 7.0) | - (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | - ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") - # Data-flow queries can repeat the same sentence once per source/flow reaching - # the same sink - collapse repeats while preserving first-seen order. - | [splits("(?<=[.!?]) ")] as $sentences - | reduce $sentences[] as $s ([]; if any(.[]; . == $s) then . else . + [$s] end) - | join(" ")) as $msg | - ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | - (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | - "| `\($rid)` | \($loc) | \($msg) |"' - done - } > codeql-medium-findings.md - fi - # Generate low findings detail file for informational display in the PR comment (collapsed) - if [[ "$LOW_COUNT" -gt 0 ]]; then - MERGE_SHA=${MERGE_SHA:-$(git rev-parse HEAD)} - { - echo "| Rule | Location | Description |" - echo "|------|----------|-------------|" - for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do - [[ -f "$f" ]] || continue - if [[ "$f" == *.gz ]]; then - FC=$(gunzip -c "$f") - else - FC=$(cat "$f") - fi - echo "$FC" | jq -r \ - --arg repo "$GITHUB_REPOSITORY" \ - --arg sha "$MERGE_SHA" \ - --argjson external_prefixes "$EXTERNAL_PREFIXES" \ - '.runs[] | - . as $run | - ( - [ (($run.tool.driver.rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}), - ((($run.tool.extensions // [])[].rules // [])[] | {key: (.id // ""), value: ((.properties["security-severity"] // "0") | tostring)}) ] - | from_entries - ) as $secmap | - ($run.results // [])[] | - select((.suppressions // []) | length == 0) | - . as $result | - (($result.ruleId // $result.rule.id // "") | tostring) as $rid | - (($secmap[$rid] // "0") | tonumber) as $sev | - select($sev < 6.0) | - (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | - ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") - # Data-flow queries can repeat the same sentence once per source/flow reaching - # the same sink - collapse repeats while preserving first-seen order. - | [splits("(?<=[.!?]) ")] as $sentences - | reduce $sentences[] as $s ([]; if any(.[]; . == $s) then . else . + [$s] end) - | join(" ")) as $msg | - ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | - (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | - "| `\($rid)` | \($loc) | \($msg) |"' - done - } > codeql-low-findings.md - fi - # Generate suppressed findings detail file for informational display in the PR comment. - # These were excluded from the blocking/medium/low tables above because CodeQL's own - # engine recognized and applied an inline suppression comment. - if [[ "$SUPPRESSED_COUNT" -gt 0 ]]; then - MERGE_SHA=${MERGE_SHA:-$(git rev-parse HEAD)} - { - echo "| Rule | Location | Description |" - echo "|------|----------|-------------|" - for f in sarif-results/*.sarif sarif-results/*.sarif.gz; do - [[ -f "$f" ]] || continue - if [[ "$f" == *.gz ]]; then - FC=$(gunzip -c "$f") - else - FC=$(cat "$f") - fi - echo "$FC" | jq -r \ - --arg repo "$GITHUB_REPOSITORY" \ - --arg sha "$MERGE_SHA" \ - --argjson external_prefixes "$EXTERNAL_PREFIXES" \ - '.runs[] | - . as $run | - ($run.results // [])[] | - select((.suppressions // []) | length > 0) | - . as $result | - (($result.ruleId // $result.rule.id // "") | tostring) as $rid | - (.locations[0].physicalLocation.artifactLocation.uri // "?") as $uri | - ((.locations[0].physicalLocation.region.startLine // "?") | tostring) as $line | - (.message.text // "no description" | gsub("\n"; " ") | gsub("://"; "\u200b://") | gsub("www\\."; "www\u200b.") | gsub("#(?=[0-9])"; "#\u200b") | gsub("\\[(?[^\\]]+)\\][(][0-9]+[)]"; .c) | gsub("\\["; "[") | gsub("\\]"; "]") - # Data-flow queries can repeat the same sentence once per source/flow reaching - # the same sink - collapse repeats while preserving first-seen order. - | [splits("(?<=[.!?]) ")] as $sentences - | reduce $sentences[] as $s ([]; if any(.[]; . == $s) then . else . + [$s] end) - | join(" ")) as $msg | - ([$external_prefixes[] | . as $p | $uri | startswith($p)] | any) as $is_external | - (if ($uri != "?" and $line != "?" and ($is_external | not)) then "[\($uri):\($line)](https://github.com/\($repo)/blob/\($sha)/\($uri)#L\($line))" else "\($uri):\($line)" end) as $loc | - "| `\($rid)` | \($loc) | \($msg) |"' - done - } > codeql-suppressed-findings.md - fi - ANALYZE_FAILED=false - if [[ "${{ steps.detect-langs.outputs.found }}" == 'true' && "${{ steps.analyze.outcome }}" != "success" && "${{ steps.analyze.outcome }}" != "" ]]; then - ANALYZE_FAILED=true - fi - - UNSCANNED_CSV="${{ steps.detect-langs.outputs.unscanned_langs }}" - CONFIG_ERROR_LANGS="" - - # CodeQL sets CODEQL_ACTION_JOB_STATUS=JOB_STATUS_CONFIGURATION_ERROR when it found - # no indexable source for a requested language (e.g. a committed file that's only - # vendored/minified/generated code, which CodeQL's own extractor refuses to treat as - # source). That's not a security finding or a broken analysis - don't block the PR on - # it, just note which language(s) went unscanned. Any other non-success outcome (a - # real extractor crash, OOM, etc.) still fails the job below. - if [[ "$ANALYZE_FAILED" == 'true' && "${CODEQL_ACTION_JOB_STATUS:-}" == 'JOB_STATUS_CONFIGURATION_ERROR' ]]; then - echo "::warning::CodeQL reported a configuration error (no indexable source found) for language(s): ${{ steps.detect-langs.outputs.languages }}. Treating as skipped instead of failing the PR." - CONFIG_ERROR_LANGS="codeql-config-error($(echo '${{ steps.detect-langs.outputs.languages }}' | tr ',' '+'))" - UNSCANNED_CSV="${UNSCANNED_CSV:+$UNSCANNED_CSV,}${CONFIG_ERROR_LANGS}" - ANALYZE_FAILED=false - fi - - if [[ "$RESULT_COUNT" -gt 0 || "$ANALYZE_FAILED" == 'true' ]]; then - echo "codeql_status=failure" >> "$GITHUB_OUTPUT" - elif [[ "${{ steps.detect-langs.outputs.found }}" == 'false' || -n "$CONFIG_ERROR_LANGS" ]]; then - echo "codeql_status=skipped" >> "$GITHUB_OUTPUT" - else - echo "codeql_status=success" >> "$GITHUB_OUTPUT" - fi - echo "codeql_unscanned_langs=$UNSCANNED_CSV" >> "$GITHUB_OUTPUT" - - - name: Upload findings detail for PR comment - if: always() && steps.status.outputs.codeql_status == 'failure' - uses: actions/upload-artifact@v7 - with: - name: codeql-findings - path: codeql-findings.md - if-no-files-found: ignore - - - name: Upload medium findings detail for PR comment - if: always() - uses: actions/upload-artifact@v7 - with: - name: codeql-medium-findings - path: codeql-medium-findings.md - if-no-files-found: ignore - - - name: Upload low findings detail for PR comment - if: always() - uses: actions/upload-artifact@v7 - with: - name: codeql-low-findings - path: codeql-low-findings.md - if-no-files-found: ignore - - - name: Upload suppressed findings detail for PR comment - if: always() - uses: actions/upload-artifact@v7 - with: - name: codeql-suppressed-findings - path: codeql-suppressed-findings.md - if-no-files-found: ignore - - - name: Apply/clear CodeQL suppression label - # Purely informational for auto-merge gating (see auto-merge-updates.yml) - never - # fails the job, so a maintainer can still merge by hand after reviewing. - if: always() - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - SUPPRESSED_COUNT: ${{ steps.status.outputs.codeql_suppressed }} - run: | - gh label create "CodeQL Suppression Used" \ - --color "FBCA04" \ - --description "PR relies on an inline CodeQL suppression comment - requires maintainer review" \ - --repo "$GH_REPO" 2>/dev/null || true - if [[ "${SUPPRESSED_COUNT:-0}" -gt 0 ]]; then - gh pr edit "$PR_NUMBER" --add-label "CodeQL Suppression Used" --repo "$GH_REPO" - else - gh pr edit "$PR_NUMBER" --remove-label "CodeQL Suppression Used" --repo "$GH_REPO" 2>/dev/null || true - fi - - - name: Fail job if CodeQL found high/error/critical issues - if: always() && steps.status.outputs.codeql_status == 'failure' - run: exit 1 + if: needs.validate-plugin.result == 'success' && needs.detect-changes.outputs.skip_validation != 'true' && needs.detect-changes.outputs.metadata_only != 'true' && needs.validate-title.outputs.title_valid == 'true' + uses: ./.github/workflows/_codeql-scan.yml + with: + matrix: ${{ needs.detect-changes.outputs.matrix }} + pr_number: ${{ github.event.pull_request.number }} + base_ref: ${{ github.event.pull_request.base.ref }} # -------------------------------------------------------------------------- - # Job 6: ClamAV antivirus scan (runs after validate-plugin) + # Job 6: ClamAV antivirus scan (reusable workflow) # -------------------------------------------------------------------------- clamav-scan: needs: [detect-changes, validate-plugin, validate-title] if: needs.validate-plugin.result == 'success' && needs.detect-changes.outputs.skip_validation != 'true' && needs.validate-title.outputs.title_valid == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - clamav_status: ${{ steps.status.outputs.clamav_status }} - clamav_infected: ${{ steps.status.outputs.clamav_infected }} - steps: - - name: Checkout PR merge commit for scan - uses: actions/checkout@v6 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/merge - sparse-checkout: .gitignore - sparse-checkout-cone-mode: false - # Merge ref is only virus-scanned by ClamAV; fork code is not executed. - allow-unsafe-pr-checkout: true - - - name: Limit checkout to changed plugin folders only - run: | - echo '${{ needs.detect-changes.outputs.matrix }}' \ - | jq -r '.[] | "plugins/\(.)"' \ - | git sparse-checkout set --stdin - git checkout - - - name: Get cache keys - id: cache-keys - run: | - echo "week=$(date +%Y-W%V)" >> $GITHUB_OUTPUT - echo "date=$(date +%Y%m%d)" >> $GITHUB_OUTPUT - - - name: Cache ClamAV installation (weekly) - id: cache-clamav-install - uses: actions/cache@v5 - with: - path: /tmp/clamav-apt - key: clamav-install-${{ runner.os }}-${{ steps.cache-keys.outputs.week }} - restore-keys: clamav-install-${{ runner.os }}- - - - name: Cache ClamAV virus definitions (daily) - id: cache-clamav-defs - uses: actions/cache@v5 - with: - path: /tmp/clamav-db - key: clamav-defs-${{ runner.os }}-${{ steps.cache-keys.outputs.date }} - restore-keys: clamav-defs-${{ runner.os }}- - - - name: Install ClamAV - run: | - sudo apt-get update -qq - if [[ "${{ steps.cache-clamav-install.outputs.cache-hit }}" == 'true' ]]; then - echo "Installing ClamAV from cached packages..." - sudo cp /tmp/clamav-apt/*.deb /var/cache/apt/archives/ 2>/dev/null || true - fi - sudo apt-get install -y --no-install-recommends clamav - if [[ "${{ steps.cache-clamav-install.outputs.cache-hit }}" != 'true' ]]; then - echo "Saving ClamAV packages to cache..." - mkdir -p /tmp/clamav-apt - find /var/cache/apt/archives/ \( -name 'clamav*.deb' -o -name 'libclamav*.deb' \) \ - -exec cp {} /tmp/clamav-apt/ \; 2>/dev/null || true - fi + uses: ./.github/workflows/_clamav-scan.yml + with: + matrix: ${{ needs.detect-changes.outputs.matrix }} + pr_number: ${{ github.event.pull_request.number }} + base_ref: ${{ github.event.pull_request.base.ref }} + metadata_only: ${{ needs.detect-changes.outputs.metadata_only }} + gh_app_id: ${{ vars.GH_APP_ID }} + secrets: + gh_app_private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Update virus definitions - run: | - sudo systemctl stop clamav-freshclam 2>/dev/null || true - sudo mkdir -p /tmp/clamav-db - sudo chown -R clamav:clamav /tmp/clamav-db - if [[ "${{ steps.cache-clamav-defs.outputs.cache-hit }}" != 'true' ]]; then - echo "Downloading fresh ClamAV definitions..." - sudo freshclam --datadir=/tmp/clamav-db - else - echo "Using cached ClamAV definitions" - fi - - - name: Scan changed plugin directories - id: scan - run: | - SCAN_TARGETS=() - EXT_SCAN_DIR="/tmp/external-scan" - mkdir -p "$EXT_SCAN_DIR" - - while IFS= read -r plugin_name; do - plugin_json="plugins/$plugin_name/plugin.json" - [[ ! -f "$plugin_json" ]] && continue - - source_type=$(jq -r '.source_type // "local"' "$plugin_json") - - if [[ "$source_type" == "external" ]]; then - version=$(jq -r '.version' "$plugin_json") - source_url_template=$(jq -r '.source_url // ""' "$plugin_json") - source_url="${source_url_template//\{version\}/$version}" - - echo "Downloading external ZIP for ClamAV scan: $source_url" - scan_dir="$EXT_SCAN_DIR/$plugin_name" - mkdir -p "$scan_dir/extracted" - zip_file="$scan_dir/${plugin_name}.zip" - - if curl -fsSL "$source_url" -o "$zip_file" --max-time 60; then - unzip -q "$zip_file" -d "$scan_dir/extracted" || true - SCAN_TARGETS+=("$scan_dir/extracted") - else - echo "::warning::Could not download $source_url — scanning plugin.json only for $plugin_name" - SCAN_TARGETS+=("plugins/$plugin_name") - fi - else - SCAN_TARGETS+=("plugins/$plugin_name") - fi - done < <(echo '${{ needs.detect-changes.outputs.matrix }}' | jq -r '.[]') - - echo "Scanning: ${SCAN_TARGETS[*]}" - set +e - clamscan --database=/tmp/clamav-db \ - --recursive --infected --no-summary \ - "${SCAN_TARGETS[@]}" > clamav-output.txt 2>&1 - echo "exit_code=$?" >> $GITHUB_OUTPUT - set -e - cat clamav-output.txt - - - name: Set ClamAV status outputs - id: status - if: always() - env: - SCAN_EXIT: ${{ steps.scan.outputs.exit_code }} - run: | - INFECTED=0 - if [[ -f "clamav-output.txt" ]]; then - INFECTED=$(grep -c ' FOUND$' clamav-output.txt || true) - fi - echo "clamav_infected=$INFECTED" >> "$GITHUB_OUTPUT" - - if [[ "$INFECTED" -gt 0 ]]; then - { - echo "| File | Signature |" - echo "|------|-----------|" - grep ' FOUND$' clamav-output.txt | while IFS= read -r line; do - FULL_PATH=$(echo "$line" | sed 's/: .* FOUND$//') - FILE=$(echo "$FULL_PATH" | sed "s|^${GITHUB_WORKSPACE}/||") - SIG=$(echo "$line" | sed 's/^[^:]*: //; s/ FOUND$//') - HASH=$(sha256sum "$FULL_PATH" 2>/dev/null | cut -d' ' -f1 || true) - if [[ -n "$HASH" ]]; then - echo "| \`$FILE\` | [\`$SIG\`](https://www.virustotal.com/gui/file/${HASH}) |" - else - echo "| \`$FILE\` | \`$SIG\` |" - fi - done - } > clamav-findings.md - echo "clamav_status=failure" >> "$GITHUB_OUTPUT" - elif [[ "${SCAN_EXIT:-0}" -ge 2 ]]; then - echo "clamav_status=failure" >> "$GITHUB_OUTPUT" - else - echo "clamav_status=success" >> "$GITHUB_OUTPUT" - fi - - - name: Upload ClamAV findings for PR comment - if: always() && steps.status.outputs.clamav_status == 'failure' - uses: actions/upload-artifact@v7 - with: - name: clamav-findings - path: clamav-findings.md - if-no-files-found: ignore - - - name: Load app ID from config - if: always() && steps.status.outputs.clamav_status == 'failure' - 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: always() && steps.status.outputs.clamav_status == 'failure' && steps.config.outputs.use_app == 'true' - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ steps.config.outputs.app_id }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - - name: Apply quarantine label - if: always() && steps.status.outputs.clamav_status == 'failure' - env: - GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} - run: | - gh label create "QUARANTINE" \ - --color "FFFF00" \ - --description "ClamAV detected a potential threat in this PR" \ - --repo "$GITHUB_REPOSITORY" 2>/dev/null || true - gh pr edit "${{ github.event.pull_request.number }}" \ - --add-label "QUARANTINE" \ - --repo "$GITHUB_REPOSITORY" - - - name: Fail job if ClamAV detected threats - if: always() && steps.status.outputs.clamav_status == 'failure' - run: exit 1 + # -------------------------------------------------------------------------- + # Job 6b: Tooling test suite (reusable workflow) for authorized repo updates + # -------------------------------------------------------------------------- + test-suite: + needs: [detect-changes] + if: >- + needs.detect-changes.result == 'success' && + needs.detect-changes.outputs.outside_files != '' && + needs.detect-changes.outputs.outside_violation != 'true' && + needs.detect-changes.outputs.close_pr == 'false' + uses: ./.github/workflows/tests.yml + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge # -------------------------------------------------------------------------- # Job 7: Validate each plugin in parallel via matrix @@ -1129,79 +280,42 @@ jobs: matrix: plugin: ${{ fromJson(needs.detect-changes.outputs.matrix) }} steps: - - name: Checkout base branch scripts (trusted) + # A local composite action must exist in the workspace before `uses:` can load it. + - name: Checkout trusted action uses: actions/checkout@v6 with: - repository: ${{ github.repository }} ref: ${{ github.event.pull_request.base.ref }} - fetch-depth: 0 - sparse-checkout: .github/scripts + fetch-depth: 1 + sparse-checkout: .github/actions/trusted-checkout sparse-checkout-cone-mode: false - - name: Fetch base branch - run: git fetch origin ${{ github.event.pull_request.base.ref }} - - - name: Save trusted scripts before fork checkout - run: cp -r .github/scripts /tmp/trusted-scripts - - - name: Checkout PR plugins (untrusted content only) - uses: actions/checkout@v6 + - name: Trusted checkout + uses: ./.github/actions/trusted-checkout with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - sparse-checkout: plugins - sparse-checkout-cone-mode: false - clean: false - # Fork content is only read as data (sparse plugins/), never executed; - # trusted scripts are saved/restored around this step. - allow-unsafe-pr-checkout: true - - - name: Restore trusted scripts - run: mkdir -p .github && cp -r /tmp/trusted-scripts .github/scripts - - - 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 + base-ref: ${{ github.event.pull_request.base.ref }} + head-repo: ${{ github.event.pull_request.head.repo.full_name }} + head-sha: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.repository }} - - name: Generate GitHub App token - if: steps.config.outputs.use_app == 'true' - id: app-token - uses: actions/create-github-app-token@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + GH_TOKEN: ${{ steps.token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} run: | - chmod +x .github/scripts/validate/*.sh set +e - .github/scripts/validate/validate.sh \ - "${{ matrix.plugin }}" \ - "${{ github.event.pull_request.user.login }}" \ - "${{ github.event.pull_request.base.ref }}" \ - "${{ matrix.plugin }}.fragment.md" + python -m pluginctl validate \ + --plugin "${{ matrix.plugin }}" \ + --author "${{ github.event.pull_request.user.login }}" \ + --base-ref "${{ github.event.pull_request.base.ref }}" \ + --out "${{ matrix.plugin }}.fragment.md" echo "exit_code=$?" >> $GITHUB_OUTPUT continue-on-error: true @@ -1220,45 +334,26 @@ jobs: # Job 8: Aggregate all fragments, post PR comment, set final status # -------------------------------------------------------------------------- report: - needs: [detect-changes, validate-plugin, codeql-analyze, clamav-scan, validate-title] - if: always() && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' && (needs.detect-changes.outputs.skip_validation != 'true' || needs.detect-changes.outputs.pub_key_changed == 'true' || needs.validate-title.outputs.title_valid == 'false') + needs: [detect-changes, validate-plugin, codeql-analyze, clamav-scan, validate-title, test-suite] + if: always() && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' && (needs.detect-changes.outputs.skip_validation != 'true' || needs.detect-changes.outputs.pub_key_changed == 'true' || needs.validate-title.outputs.title_valid == 'false' || needs.test-suite.result == 'failure') runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Checkout scripts + - name: Checkout config uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.base.ref }} fetch-depth: 1 - sparse-checkout: .github/scripts + sparse-checkout: .github 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@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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@v8 with: @@ -1268,6 +363,7 @@ jobs: continue-on-error: true - name: Download CodeQL findings detail + if: needs.codeql-analyze.outputs.codeql_errors != '0' uses: actions/download-artifact@v8 with: name: codeql-findings @@ -1275,6 +371,7 @@ jobs: continue-on-error: true - name: Download CodeQL medium findings detail + if: needs.codeql-analyze.outputs.codeql_mediums != '0' uses: actions/download-artifact@v8 with: name: codeql-medium-findings @@ -1282,6 +379,7 @@ jobs: continue-on-error: true - name: Download CodeQL low findings detail + if: needs.codeql-analyze.outputs.codeql_lows != '0' uses: actions/download-artifact@v8 with: name: codeql-low-findings @@ -1289,13 +387,31 @@ jobs: continue-on-error: true - name: Download CodeQL suppressed findings detail + if: needs.codeql-analyze.outputs.codeql_suppressed != '0' uses: actions/download-artifact@v8 with: name: codeql-suppressed-findings path: codeql-suppressed-findings continue-on-error: true + - name: Download CodeQL sandbox-bypass findings detail + if: needs.codeql-analyze.outputs.codeql_sandbox_bypass != '0' + uses: actions/download-artifact@v8 + with: + name: codeql-sandbox-findings + path: codeql-sandbox-findings + continue-on-error: true + + - name: Download CodeQL capability-contract findings detail + if: needs.codeql-analyze.outputs.codeql_capability_contract != '0' + uses: actions/download-artifact@v8 + with: + name: codeql-capability-contract-findings + path: codeql-capability-contract-findings + continue-on-error: true + - name: Download ClamAV findings detail + if: needs.clamav-scan.outputs.clamav_infected != '0' uses: actions/download-artifact@v8 with: name: clamav-findings @@ -1305,7 +421,7 @@ jobs: - name: Aggregate and post comment id: report env: - GH_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + GH_TOKEN: ${{ steps.token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} DISCORD_URL: ${{ vars.DISCORD_URL }} CODEQL_RESULT: ${{ needs.codeql-analyze.outputs.codeql_status }} @@ -1314,6 +430,8 @@ jobs: CODEQL_MEDIUMS: ${{ needs.codeql-analyze.outputs.codeql_mediums }} CODEQL_LOWS: ${{ needs.codeql-analyze.outputs.codeql_lows }} CODEQL_SUPPRESSED: ${{ needs.codeql-analyze.outputs.codeql_suppressed }} + CODEQL_SANDBOX_BYPASS: ${{ needs.codeql-analyze.outputs.codeql_sandbox_bypass }} + CODEQL_CAPABILITY_CONTRACT: ${{ needs.codeql-analyze.outputs.codeql_capability_contract }} CODEQL_UNSCANNED_LANGS: ${{ needs.codeql-analyze.outputs.codeql_unscanned_langs }} CLAMAV_RESULT: ${{ needs.clamav-scan.outputs.clamav_status }} CLAMAV_INFECTED: ${{ needs.clamav-scan.outputs.clamav_infected }} @@ -1323,16 +441,18 @@ jobs: TITLE_VALID: ${{ needs.validate-title.outputs.title_valid }} TITLE_FEEDBACK: ${{ needs.validate-title.outputs.title_feedback }} TITLE_SUGGESTION: ${{ needs.validate-title.outputs.title_suggestion }} + TEST_RESULT: ${{ needs.test-suite.result }} BASE_REF: ${{ github.event.pull_request.base.ref }} + WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} run: | - chmod +x .github/scripts/validate/*.sh set +e - .github/scripts/validate/report.sh \ - "${{ github.event.pull_request.number }}" \ - "${{ github.event.pull_request.user.login }}" \ - "${{ needs.detect-changes.outputs.plugin_count }}" \ - "false" \ - "fragments" + python -m pluginctl report \ + --pr "${{ github.event.pull_request.number }}" \ + --author "${{ github.event.pull_request.user.login }}" \ + --plugin-count "${{ needs.detect-changes.outputs.plugin_count }}" \ + --close-pr false \ + --fragments-dir fragments echo "exit_code=$?" >> $GITHUB_OUTPUT - name: Fail workflow if any plugin failed @@ -1350,71 +470,67 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Checkout scripts + - name: Checkout config uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.base.ref }} fetch-depth: 1 - sparse-checkout: .github/scripts + sparse-checkout: .github 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@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} + GH_TOKEN: ${{ steps.token.outputs.token }} GITHUB_REPOSITORY: ${{ github.repository }} DISCORD_URL: ${{ vars.DISCORD_URL }} CLOSE_REASON: ${{ needs.detect-changes.outputs.close_reason }} + WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} run: | - chmod +x .github/scripts/validate/*.sh - .github/scripts/validate/report.sh \ - "${{ github.event.pull_request.number }}" \ - "${{ github.event.pull_request.user.login }}" \ - "${{ needs.detect-changes.outputs.plugin_count }}" \ - "true" \ - "/dev/null" + python -m pluginctl report \ + --pr "${{ github.event.pull_request.number }}" \ + --author "${{ github.event.pull_request.user.login }}" \ + --plugin-count "${{ needs.detect-changes.outputs.plugin_count }}" \ + --close-pr true \ + --fragments-dir /dev/null # -------------------------------------------------------------------------- - # Job 10: Gate - single fixed status check for branch protection rules - # Reference this job by name "Plugin PR Check" in your branch protection. - # Passes only when all jobs succeed. Fails for any error, including - # unauthorized PRs. + # Job 10: Gate, single fixed status check for branch protection rules # -------------------------------------------------------------------------- plugin-pr-check: name: Plugin PR Check - needs: [detect-changes, codeql-analyze, clamav-scan, validate-plugin, validate-title, report] + needs: [detect-changes, codeql-analyze, clamav-scan, validate-plugin, validate-title, report, test-suite] if: always() runs-on: ubuntu-latest timeout-minutes: 2 steps: + - name: Checkout config + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1 + sparse-checkout: | + .github/pluginctl + .github/actions/gh-app-token + sparse-checkout-cone-mode: false + + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - name: Check for quarantine label env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.token.outputs.token || github.token }} run: | LABELS=$(gh pr view "${{ github.event.pull_request.number }}" \ --repo "${{ github.repository }}" \ @@ -1437,41 +553,62 @@ jobs: CLAMAV_STATUS: ${{ needs.clamav-scan.outputs.clamav_status }} VALIDATE_RESULT: ${{ needs.validate-plugin.result }} REPORT_RESULT: ${{ needs.report.result }} + TEST_RESULT: ${{ needs.test-suite.result }} + run: python -m pluginctl gate + + - name: Request maintainer review after successful validation + if: success() && (needs.detect-changes.outputs.has_new_plugin == 'true' || needs.codeql-analyze.outputs.codeql_suppressed != '0' || needs.codeql-analyze.outputs.codeql_sandbox_bypass_detected != '0' || needs.codeql-analyze.outputs.codeql_capability_contract_detected != '0') + env: + GH_TOKEN: ${{ steps.token.outputs.token || github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + DISCORD_WEBHOOKS: ${{ secrets.DISCORD_WEBHOOKS }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HAS_NEW_PLUGIN: ${{ needs.detect-changes.outputs.has_new_plugin }} run: | - if [[ "$DETECT_RESULT" != "success" ]]; then - echo "::error::Plugin detection failed or no plugin changes found." - exit 1 - fi - if [[ "$SKIP_VALIDATION" == "true" ]]; then - echo "No plugin changes detected and author has write access - passing." + labels=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json labels --jq '[.labels[].name] | join("\n")') + if printf '%s\n' "$labels" | grep -qx 'Manual Review Required'; then exit 0 fi - if [[ "$TITLE_RESULT" == "failure" ]]; then - echo "::error::PR title does not match the required format. Rename the PR and re-run." - exit 1 - fi - if [[ "$OUTSIDE_VIOLATION" == "true" ]]; then - echo "::error::PR contains unauthorized changes outside the plugins/ directory." - exit 1 - fi - if [[ "$CLOSE_PR" == "true" ]]; then - echo "::error::PR is unauthorized - no permission to modify these plugins." - exit 1 - fi - if [[ "$CODEQL_RESULT" == "failure" || "$CODEQL_STATUS" == "failure" ]]; then - echo "::error::CodeQL security analysis failed. See the Security tab for details." - exit 1 - fi - if [[ "$CLAMAV_RESULT" == "failure" || "$CLAMAV_STATUS" == "failure" ]]; then - echo "::error::ClamAV antivirus scan detected threats. See PR comment for details." - exit 1 - fi - if [[ "$VALIDATE_RESULT" == "failure" || "$VALIDATE_RESULT" == "cancelled" ]]; then - echo "::error::One or more plugin validations failed. See PR comment for details." - exit 1 - fi - if [[ "$REPORT_RESULT" != "success" ]]; then - echo "::error::Plugin validation failed. See PR comment for details." - exit 1 - fi - echo "All plugins validated successfully." + gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label 'Manual Review Required' + if [[ "$HAS_NEW_PLUGIN" == "true" ]]; then + export reason='New plugins require maintainer review.' + else + export reason='CodeQL findings require maintainer review.' + fi + python - <<'PY' + import base64 + import json + import os + from pluginctl.core import gh + from pluginctl.integrations import webhooks + pr = os.environ["PR_NUMBER"] + repo = os.environ["GITHUB_REPOSITORY"] + files = gh.pr_files(pr, repo) or [] + manifests = [path for path in files if path.startswith("plugins/") and path.endswith("/plugin.json")] + slug = manifests[0].split("/")[1] if manifests else "unknown" + plugin = {} + if slug != "unknown": + content = gh.loads(gh.api(f"repos/{repo}/contents/plugins/{slug}/plugin.json?ref={os.environ['PR_HEAD_SHA']}")) or {} + try: + plugin = json.loads(base64.b64decode(content["content"]).decode("utf-8")) + except (KeyError, ValueError, TypeError): + pass + logo_url = "" + for ext in ("png", "svg", "jpg", "webp"): + path = f"plugins/{slug}/logo.{ext}" + if slug != "unknown" and gh.api(f"repos/{repo}/contents/{path}?ref={os.environ['PR_HEAD_SHA']}"): + logo_url = f"https://raw.githubusercontent.com/{repo}/{os.environ['PR_HEAD_SHA']}/{path}" + break + payload = { + "pr": pr, "author": plugin.get("author") or os.environ["PR_AUTHOR"], "plugins": [slug], + "plugin_name": plugin.get("name") or slug, "description": plugin.get("description") or "", + "reason": os.environ.get("reason", "Manual review is required."), + "reason_code": "new_plugin" if os.environ["HAS_NEW_PLUGIN"] == "true" else "codeql_review", + "pr_url": f"https://github.com/{repo}/pull/{pr}", + "logo_url": logo_url, + } + webhooks.emit("pr.review_required", payload) + webhooks.emit_discord("pr.review_required", payload, repository=repo) + PY diff --git a/.github/workflows/yank-plugin-version.yml b/.github/workflows/yank-plugin-version.yml index bac13b28..954313ae 100644 --- a/.github/workflows/yank-plugin-version.yml +++ b/.github/workflows/yank-plugin-version.yml @@ -7,6 +7,7 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONPATH: .github/pluginctl/src on: workflow_dispatch: @@ -34,39 +35,17 @@ 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@v3 + - name: GitHub App token + id: token + uses: ./.github/actions/gh-app-token with: - client-id: ${{ steps.config.outputs.app_id }} + app-id: ${{ vars.GH_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: Yank plugin version env: - GITHUB_TOKEN: ${{ steps.config.outputs.use_app == 'true' && steps.app-token.outputs.token || github.token }} - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + APP_SLUG: ${{ steps.token.outputs.app-slug }} GITHUB_REPOSITORY: ${{ github.repository }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} @@ -74,9 +53,11 @@ jobs: YANK_VERSION: ${{ inputs.version }} YANK_ISSUE: ${{ inputs.issue_number }} SOURCE_BRANCH: ${{ github.ref_name }} + WEBHOOK_URL: ${{ vars.WEBHOOK_URL }} + WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} run: | set -o pipefail - .github/scripts/publish/yank-version.sh 2>&1 | tee yank.log + python -m pluginctl yank 2>&1 | tee yank.log - name: Generate job summary if: always() diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..9d59888e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,25 @@ +> [!CAUTION] +> The repository's workflows recently changed. Please bear with us while we work through expected issues and hiccups. [Report a workflow issue](https://github.com/Dispatcharr/Plugins/issues/new?template=repo-issue.yml). + +# Code of Conduct + +Dispatcharr welcomes contributions that help people manage content they are authorized to access and distribute. Contributors are responsible for ensuring that their plugins, documentation, and support activities comply with applicable law and the rights of content owners. + +## Repository rules + +1. **No illegal or unauthorized activity:** Do not include, distribute, link to, facilitate access to, or enable illegal content, copyright infringement, unauthorized access, credential theft, or evasion of access controls. +2. **No unauthorized resale:** Do not submit contributions designed to facilitate reselling IPTV services, access to content, or third-party services without authorization from the relevant rights holder. +3. **No paid plugins:** Do not require payment, a subscription, or a paid service operated by the plugin's publisher as a condition of using the plugin. Plugins may integrate with external paid services that users independently choose to obtain and pay for. +4. **No undisclosed phone-home behavior:** Do not contact an external service for telemetry, analytics, licensing, updates, or other phone-home behavior without clearly labeling that behavior and obtaining the user's explicit consent. +5. **No abandonware:** Do not submit abandonware or submissions without a committed maintainer or a plan for maintenance, transfer, or removal. (These may be removed or unlisted without warning at repository maintainer discretion.) +6. **No third-party terms violations:** Do not violate the license, terms of service, or acceptable-use policy of a service that the plugin integrates with. +7. **No sandbox or capability-list circumvention:** Do not bypass the plugin sandbox or circumvent required capability declarations (for manifest versions where they are required). +8. **No incomplete pull requests:** Do not submit a pull request with a blank or materially incomplete description. Doing so may result in auto-merge privileges being revoked. + +These rules may change at any time without notice, at the discretion of the Dispatcharr team. + +## Reporting and enforcement + +Report suspected violations privately to a project maintainer through the [Dispatcharr Discord](https://discord.gg/Sp45V5BcxU). Include links or other evidence when possible. Maintainers will review reports promptly and handle them with discretion. + +This Code of Conduct guides maintainer decisions but does not limit their authority over repository submissions. Maintainers may remove, deny, or restrict submissions; close issues or pull requests; disable distribution; or ban contributors. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c4d2fe3..95abc414 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,83 +1,57 @@ -# Contributing to the Dispatcharr Plugin Repository +> [!CAUTION] +> The repository's workflows recently changed. Please bear with us while we work through expected issues and hiccups. [Report a workflow issue](https://github.com/Dispatcharr/Plugins/issues/new?template=repo-issue.yml). -> **This is a listing and distribution repository, not a development environment.** -> Build, test, and iterate on your plugin in your own repository. Pre-releases, work-in-progress versions, and experiments belong there too. Only submit a PR here when your plugin is stable and ready for public distribution. +# Contributing -## Before You Start +This repository is a public plugin registry, not a plugin development environment. Build, test, and iterate in your own repository. Open a pull request here when a stable version is ready for distribution. -- Build and test your plugin in your own repository first -- Ensure your plugin is stable - this repo is for public releases, not pre-releases or experiments -- You must own the rights to distribute it under an OSI-approved open source license -- Each plugin lives in its own folder under `plugins//` +## Before you begin -## Folder Structure +- Test the plugin with Dispatcharr. +- Confirm you have the right to distribute it under an [OSI-approved open source license](https://opensource.org/licenses). +- Decide whether the registry should package source here or retrieve a release ZIP from your own repository. +- Confirm the submission meets the [Code of Conduct](CODE_OF_CONDUCT.md) contribution boundaries. -### External plugin (recommended) +## Choose a submission type -If your plugin has its own GitHub repository, submit a directory with only a `plugin.json`. The registry fetches your ZIP on each version bump from your upstream release URL. +| Choose this | When | What you add here | +| --- | --- | --- | +| **External plugin** | The plugin has its own public source repository and release ZIPs. Recommended. | `plugin.json`, plus an optional README and logo. | +| **Standard plugin** | The plugin is small or has no separate release process. | `plugin.json` and all source files, plus an optional README and logo. | -``` -plugins/ - your-plugin-name/ - plugin.json # required; includes source_type and source_url - README.md # optional but recommended - logo.png # optional; displayed in the plugin browser -``` - -On merge, the registry downloads the ZIP from your `source_url`, computes its checksums independently, re-hosts it as a GitHub Release on this registry, and GPG-signs the manifest. Clients always download from the registry, never directly from your upstream URL. - -**Requirements for external plugins:** +Every plugin directory is `plugins//`. The slug must use lowercase letters, numbers, and hyphens, such as `my-plugin`. -- `source_url` must be an HTTPS URL pointing directly to a downloadable ZIP -- `source_url` must contain a `{version}` placeholder that is substituted at publish time -- `repo_url` is required (points to your source repository) -- The source repository must be public and under an OSI-approved open source license +### External plugin -Each version bump requires a PR to this repository (updating `version` in `plugin.json`), which must be approved and merged before anything is published. How you automate or manage that is up to you. - -### Standard plugin (full source) - -For simple scripts or plugins without their own repository or build process. - -``` +```text 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 + my-plugin/ + plugin.json + README.md # optional + logo.png # optional ``` -Everything in your plugin folder (`main.py`, helper modules, assets, subdirectories) is automatically packaged into a ZIP on merge. No separate build step. +Set `source_type` to `"external"`. `source_url` must be an HTTPS URL for a downloadable ZIP and include `{version}`, which the registry replaces when it publishes. `repo_url` is required and should point to the public source repository. -Plugin folder names must be **lowercase-kebab-case** (e.g. `my-plugin-name`). +On merge, the registry downloads the release ZIP, calculates its checksums, scans it, publishes it as a release in this repository, and records the upstream source URL in signed metadata. Clients download from this registry, not directly from the upstream URL. -## Submitting a Plugin +### Standard plugin -1. Fork this repository and create a branch -2. Create your plugin folder under `plugins/your-plugin-name/` -3. Add a valid `plugin.json` (see spec below) -4. Optionally add a `README.md` and `logo.png` -5. Submit a pull request to `main` - -### PR Title Format - -PR titles must follow this format (the colon after `]` is optional): - -| Scenario | Format | Example | -|----------|--------|---------| -| Single plugin changed | `[plugin-slug] description` | `[my-plugin] Bump version to 1.2.0` | -| Multiple plugins changed | `[your-github-username] description` | `[sethwv] Update my plugins to new manifest formatting` | -| Repo/script changes (maintainers only) | `[repo] description` | `[repo] Add new validation rules for PRs` | - -The plugin slug is the folder name under `plugins/` (e.g. `my-plugin-name`). Validation checks the title automatically; renaming the PR triggers a re-run. +```text +plugins/ + my-plugin/ + plugin.json + main.py # example entry point + helpers.py # other source files and assets are allowed + README.md # optional + logo.png # optional +``` -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. +The registry packages the entire plugin directory after merge. A standard plugin must include at least one source file in addition to metadata and the optional README or logo. -## `plugin.json` Spec +## Create `plugin.json` -### Required Fields +Start with the required fields: ```json { @@ -89,31 +63,26 @@ For **updates**, increment the version in `plugin.json` - the validation workflo } ``` -| Field | Description | -|-------|-------------| -| `name` | Display name of the plugin | -| `version` | Semantic version (`MAJOR.MINOR.PATCH`) | -| `description` | Short description shown in the plugin browser | -| `author` | Your GitHub username. Used for PR permission checks - must match the GitHub account submitting the PR | -| `license` | An [OSI-approved SPDX license identifier](https://spdx.org/licenses/) (e.g. `MIT`, `Apache-2.0`, `GPL-3.0-only`) | - -At least one of `author` or `maintainers` must include your GitHub username. `author` is also part of the Dispatcharr plugin spec - it is used by this repository to determine who is permitted to submit PRs for a given plugin. - -### Optional Fields - -| Field | Type | Description | -|-------|------|-------------| -| `maintainers` | `string[]` | Additional GitHub usernames permitted to submit PRs for this plugin (in addition to `author`) | -| `min_dispatcharr_version` | `string` | Minimum Dispatcharr version required (e.g. `v0.19.0` or `0.19.0`) | -| `max_dispatcharr_version` | `string` | Maximum Dispatcharr version supported. Must be ≥ `min_dispatcharr_version` if both are set | -| `repo_url` | `string` | URL to the plugin's source repository (must start with `http://` or `https://`). Required for external plugins | -| `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` | Excludes the plugin from the root `manifest.json` (and the releases README) but still generates a per-plugin manifest. Default: `false` | -| `source_type` | `string` | Set to `"external"` to declare this as an external plugin. Omit (or `"local"`) for standard plugins | -| `source_url` | `string` | Required when `source_type` is `"external"`. Must be a GitHub Releases URL containing a `{version}` placeholder, e.g. `https://github.com/owner/repo/releases/download/v{version}/plugin.zip` | - -### Full Example (external plugin, recommended) +| Field | Required | Description | +| --- | --- | --- | +| `name` | Yes | Plugin display name. | +| `version` | Yes | Three-segment version in `MAJOR.MINOR.PATCH` form. | +| `description` | Yes | Short browser description. | +| `author` | Yes, unless `maintainers` is supplied | GitHub username allowed to update the plugin. | +| `license` | Yes | OSI-approved SPDX identifier, such as `MIT` or `Apache-2.0`. | +| `maintainers` | No | Additional GitHub usernames allowed to update the plugin. | +| `min_dispatcharr_version` | No | Minimum supported Dispatcharr version, with optional `v` prefix. | +| `max_dispatcharr_version` | No | Maximum supported Dispatcharr version. It cannot be lower than `min_dispatcharr_version`. | +| `repo_url` | No, except external plugins | Source repository URL beginning with `http://` or `https://`. | +| `discord_thread` | No | Associated Discord thread URL beginning with `http://` or `https://`. | +| `deprecated` | No | Boolean that marks the plugin as deprecated. Defaults to `false`. | +| `unlisted` | No | Boolean that omits the plugin from the root manifest and releases README. Defaults to `false`. | +| `source_type` | No | Use `"external"` for an external plugin. Omit it or use `"local"` for a standard plugin. | +| `source_url` | Required for external plugins | HTTPS ZIP URL containing `{version}`. | + +At least one of `author` or `maintainers` must name the GitHub account opening the pull request. For an existing plugin, the account must already be listed in the version on the base branch unless it has repository write permission. + +### External example ```json { @@ -124,85 +93,55 @@ At least one of `author` or `maintainers` must include your GitHub username. `au "license": "MIT", "source_type": "external", "source_url": "https://github.com/your-github-username/my-plugin/releases/download/v{version}/my-plugin.zip", - "repo_url": "https://github.com/your-github-username/my-plugin", - "discord_thread": "https://discord.com/channels/..." + "repo_url": "https://github.com/your-github-username/my-plugin" } ``` -### Full Example (standard plugin) +## Issues and support -```json -{ - "name": "My Plugin", - "version": "1.2.0", - "description": "Does something useful for Dispatcharr", - "author": "your-github-username", - "maintainers": ["collaborator-username"], - "license": "MIT", - "min_dispatcharr_version": "v0.19.0", - "repo_url": "https://github.com/your-github-username/my-plugin", - "discord_thread": "https://discord.com/channels/..." -} -``` +Use this repository's issue tracker for registry matters: validation or publishing workflow problems, submission-process issues, repository tooling, and documentation errors. [Report a repository issue](https://github.com/Dispatcharr/Plugins/issues/new?template=repo-issue.yml). + +Do not use it for bugs, compatibility problems, support requests, or feature requests for a specific plugin. Use the plugin's own source repository, linked in its `plugin.json`, or its thread in the [Dispatcharr Discord](https://discord.gg/Sp45V5BcxU). + +To remove a published plugin version from the registry, [submit a Version Removal Request](https://github.com/Dispatcharr/Plugins/issues/new?template=version-removal-request.yml). -## What Happens When You Open a PR +## Submit the pull request -Automated validation runs on every PR and posts a comment with results. The following checks must all pass before a PR can merge: +1. Fork this repository and create a branch. +2. Add or update `plugins//`. +3. Confirm `plugin.json` is valid JSON and the source ZIP is publicly reachable if the plugin is external. +4. Open a pull request against `main`. +5. Read the validation comment and address any reported failures. -| Check | Details | -|-------|---------| -| Folder name | Must be lowercase-kebab-case | -| `plugin.json` presence | File must exist | -| 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 (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 | -| `max_dispatcharr_version` | Must be semver and ≥ `min_dispatcharr_version` if both provided | -| `repo_url` / `discord_thread` | Must start with `http://` or `https://` if provided | -| CodeQL | Python code is scanned for security issues (blocking). For external plugins, the release ZIP is downloaded and its contents scanned | -| ClamAV | All submitted files are scanned for malware (blocking). For external plugins, the release ZIP is downloaded and scanned | -| `source_url` | For external plugins: must be an HTTPS URL with a `{version}` placeholder; artifact must be reachable | -| `repo_url` | Required for external plugins | -| `.github/` | Cannot be modified by non-maintainers of this repository | +Use one of these pull request title formats. A colon after `]` is optional. -PRs where the author has no permission for any of the modified plugins are **automatically closed** with instructions. PRs from accounts or plugins on the repository blocklist are also automatically closed. +| Change | Title format | Example | +| --- | --- | --- | +| One plugin | `[plugin-slug] description` | `[my-plugin] Bump version to 1.2.0` | +| Multiple plugins | `[github-username] description` | `[sethwv] Update my plugins` | +| Registry changes, maintainers only | `[repo] description` | `[repo] Update validation rules` | -## What Happens After Merge +Use of the pull request template is optional, but every pull request must include a meaningful description. A blank or materially incomplete description may result in auto-merge privileges being revoked. If you submit pull requests through automation, consider configuring it to include a link to the relevant changelog, release notes, or other change summary. -Once your PR merges to `main`, the publish workflow runs automatically: +### Automatic merging -**External plugins:** -1. The ZIP is downloaded from your `source_url` (with `{version}` substituted) -2. MD5 and SHA256 checksums are computed by the registry's infrastructure (not trusted from upstream) -3. The ZIP is published as a **GitHub Release** on this registry (tag: `your-plugin-1.0.0`); a `-latest` alias release is also maintained -4. `manifest.json` is updated with checksums and download URLs pointing to the GitHub Release assets; `source_url` pointing to the upstream release is also recorded -5. A per-plugin `README.md` is generated with download links and version history -6. Up to 10 versioned releases are retained; older ones are pruned +Automatic merging is not guaranteed. It only considers cleanly mergeable, non-draft updates to existing plugins that change files only under `plugins/` and pass validation. New plugins, repository changes, mixed changes, validation failures, security findings, and pull requests marked for manual review require maintainer review. -**Standard plugins:** -1. Your plugin is packaged into a versioned ZIP -2. MD5 and SHA256 checksums are computed -3. The ZIP is published as a **GitHub Release** on this registry (tag: `your-plugin-1.0.0`); a `-latest` alias release is also maintained -4. `manifest.json` on the releases branch is updated with metadata and download URLs pointing to the GitHub Release assets -5. A per-plugin `README.md` is generated with download links and version history -6. Up to 10 versioned releases are retained; older ones are pruned +By default, automatic merging is limited to four updates per author and four updates per plugin in a rolling 24-hour period. Maintainers may change these limits, exclude authors from automatic merging, or require review for any pull request. A successful automatic merge receives the `Auto Merged` label. -Manifests and READMEs are committed to the [`releases` branch](https://github.com/Dispatcharr/Plugins/tree/releases). ZIP files are stored as [GitHub Release assets](https://github.com/Dispatcharr/Plugins/releases). +## What validation checks -## Versioning +The workflow verifies the folder name, `plugin.json` presence and syntax, required metadata, three-segment version format, author or maintainer permission, license, Dispatcharr version constraints, optional URL fields, and external-plugin requirements. It also verifies that an external ZIP can be reached. -Plugins use [semantic versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`): +CodeQL scans submitted code for security findings. ClamAV scans submitted files and, for external plugins, the downloaded release ZIP. CodeQL capability and sandbox findings are reviewed by maintainers and prevent automatic merging. See [plugin security](docs/plugin-security.md) for checks and suppressions. -- **PATCH** - bug fixes, minor tweaks -- **MINOR** - new features, backwards compatible -- **MAJOR** - breaking changes +Only repository maintainers may change `.github/`. Pull requests from blocked authors or for blocked plugins are closed automatically. A pull request whose author cannot modify any changed plugin is also closed automatically. -Version increments are enforced by the validation workflow. You cannot submit a PR with the same or lower version than the currently published plugin. +## Versioning updates -**Metadata-only updates** are an exception - the following fields can be changed without bumping the version: +Use a three-segment version in `MAJOR.MINOR.PATCH` form. We recommend following [semantic versioning](https://semver.org/): increment PATCH for fixes, MINOR for backward-compatible features, and MAJOR for breaking changes. + +An existing plugin needs a higher version for source changes or most metadata changes. These fields may change without a version bump: - `description` - `repo_url` @@ -213,25 +152,12 @@ Version increments are enforced by the validation workflow. You cannot submit a - `deprecated` - `unlisted` -All other fields - including `name`, `author`, `license`, `source_url`, `source_type`, and any code changes - require a version bump. - -> **Changing the license?** A version bump is required because the license you publish under is binding for that release. Users who installed the previous version hold rights under the old license and those cannot be revoked. The new version carries the new license going forward. - -## 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/). - -By submitting a PR you confirm that you have the rights to distribute the plugin under the license you specify. This is binding for the version being published. Users who downloaded a version hold rights under its license permanently - those rights are not affected if the version is later removed from the registry. To change the license going forward, bump the version. The old version stays under its original license. - -## Version Removal +Changing `name`, `author`, `license`, `source_type`, `source_url`, or any source file requires a version bump. A license change creates a new release under the new license. It does not alter rights already granted by earlier releases. -Plugin authors and maintainers can request that a specific version be removed from the registry. Reasons might include a critical bug, a security issue, a mistaken publish, or a license correction. +## After merge -To request a removal, [open an issue](../../issues/new/choose) using the **Version Removal Request** template. A maintainer will run the yank workflow on your behalf. +The registry publishes a versioned ZIP and a `-latest` alias GitHub Release, calculates MD5 and SHA256 checksums, updates signed manifests, and generates a plugin README on the [`releases` branch](https://github.com/Dispatcharr/Plugins/tree/releases). It keeps the ten newest versioned releases for each plugin. -What happens when a version is yanked: +## Remove a published version -- The GitHub Release and its asset are deleted; the manifest entry is removed from the releases branch. -- If it was the latest version, the previous version is automatically promoted to latest and a PR is opened against the source branch to roll back `plugin.json` to match. -- If it was the only version, the plugin is fully removed from the registry and a PR is opened to remove its source folder. -- Users who already downloaded the version are unaffected. +To request removal of a version, open a [Version Removal Request](https://github.com/Dispatcharr/Plugins/issues/new?template=version-removal-request.yml). A maintainer runs the yank workflow. If the removed version was latest, the prior version becomes latest. If it was the only version, the plugin is removed from the registry. Existing downloads are unaffected. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..b837c872 --- /dev/null +++ b/LICENSE @@ -0,0 +1,672 @@ +Dispatcharr Plugin Registry License + +Copyright (C) 2026 Dispatcharr contributors + +The files in this repository are licensed under the GNU Affero General Public +License, version 3 or any later version, except for files under `plugins/`. +Each plugin is distributed under the license declared in its own metadata and +source files. Those plugin-specific licenses control use of that plugin. + +The full GNU Affero General Public License follows. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index c2adfd16..6a99b8e1 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,47 @@ -# Dispatcharr Plugin Repository - -> **This is a listing and distribution repository.** Plugin development, testing, and pre-releases should happen in your own repository. Submit a PR here only when your plugin is ready for public distribution. - -> AI tools are used in the development of this project and repo, as well as many of the plugins included in this repo. The team personally audits and reviews every line of workflow and script generated, and all changes are tested extensively by humans. The involvement of these tools greatly increases development velocity (nice, buzzword) and greatly assists especially where boilerplate and documentation (which we all loathe writing) are involved. - - -## Quick Links - -| Resource | Description | -|----------|-------------| -| [Browse Plugins](https://github.com/Dispatcharr/Plugins/tree/releases) | Manifests and READMEs on the releases branch | -| [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/releases) | Plugin ZIP assets on GitHub Releases | - -## How It Works - -Each plugin lives in `plugins//` and must contain a valid `plugin.json`. When a PR is merged to `main`, the publish workflow runs automatically: - -- **External plugins** (recommended): the ZIP is fetched from your upstream release URL -- **Standard plugins**: everything in the plugin folder is packaged into a ZIP - -The ZIP is then published as a **GitHub Release** on this repository. Manifests and per-plugin READMEs are committed to the [`releases` branch](https://github.com/Dispatcharr/Plugins/tree/releases). - -### PR Validation - -Every PR runs automated validation that checks: +> [!CAUTION] +> The repository's workflows recently changed. Please bear with us while we work through expected issues and hiccups. [Report a workflow issue](https://github.com/Dispatcharr/Plugins/issues/new?template=repo-issue.yml). -- Folder name is lowercase-kebab-case -- `plugin.json` is valid and contains required fields -- Version is incremented for existing plugins -- PR author is listed in `author` or `maintainers` -- `.github/` files are not modified by non-maintainers -- Python code is scanned by CodeQL (required check) -- All files are scanned by ClamAV for malware (required check) - -PRs where the author has no permission for any modified plugin are automatically closed with instructions. - -Results are posted as a comment on the PR. - -### Publishing - -On merge to `main`, each plugin is: - -- Packaged or fetched as a versioned ZIP -- Given MD5 and SHA256 checksums -- Published as a **GitHub Release** (tag: `plugin-name-1.0.0`) with a `-latest` alias release kept up to date -- Listed in `manifest.json` with download URLs pointing to the GitHub Release assets -- Only the 10 most recent versioned releases are kept per plugin - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide, including the `plugin.json` spec, validation rules, and what happens after merge. - -## Downloading Plugins - -Visit the [releases page](https://github.com/Dispatcharr/Plugins/releases) to browse and download plugins, or fetch `manifest.json` programmatically: - -```bash -curl https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json -``` +# Dispatcharr Plugin Repository -## Manifest Structure +This repository distributes plugins for Dispatcharr. Develop, test, and release your plugin in its own repository. Submit a pull request here when a version is ready for public use. -The root `manifest.json` uses a `root_url` plus relative paths for ZIP downloads. `manifest_url` is an absolute URL since per-plugin manifests live on the releases branch (not as GitHub Release assets). +> [!NOTE] +> AI tools are used in this repository and may be used in plugins. Contributors are responsible for maintaining their plugins and for the output of any tools used to develop them. -```json -{ - "generated_at": "...", - "signature": "-----BEGIN PGP SIGNATURE-----\n...", - "manifest": { - "registry_url": "https://github.com/Dispatcharr/Plugins", - "registry_name": "Dispatcharr/Plugins", - "root_url": "https://github.com/Dispatcharr/Plugins/releases/download", - "plugins": [ - { - "slug": "my-plugin", - "name": "My Plugin", - "manifest_url": "https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/metadata/my-plugin/manifest.json", - "latest_url": "my-plugin-latest/my-plugin-latest.zip", - ... - } - ] - } -} -``` +## Find plugins -To resolve a full ZIP download URL: `root_url + "/" + latest_url`. +In Dispatcharr under **Plugins**, select **Find Plugins** to browse and install published plugins. -The `slug` matches the plugin folder name and can be used to construct other paths (e.g. icon: `plugins//logo.png` on the source branch). +## Publish a plugin -## Verifying Manifest Signatures +1. Build and test the plugin in your own repository. +2. Choose how to distribute it: + - **External plugin**: keep source and release ZIPs in your own repository. This registry stores metadata and retrieves the ZIP for each published version. + - **Standard plugin**: add the plugin source directly under `plugins//`. The registry packages that directory after merge. +3. Follow the [contribution guide](CONTRIBUTING.md) to create `plugin.json`, prepare the pull request, and understand validation. -Each manifest file embeds its GPG signature directly. The `signature` field covers the compact (`jq -c '.manifest'`) form of the `manifest` payload. +Every accepted plugin is published as a GitHub Release in this repository. The registry computes MD5 and SHA256 checksums, updates signed manifests, maintains a `-latest` release, and retains the ten newest versioned releases for each plugin. -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. +## What's allowed -### Steps +Review the [Code of Conduct](CODE_OF_CONDUCT.md) before submitting a plugin. It defines the repository's contribution boundaries, including requirements for freely usable plugins and clearly disclosed, opt-in phone-home behavior. -**1. Import the public key** +## Issues and support -```bash -gpg --import dispatcharr-plugins.pub -``` +Use this repository's issue tracker for registry matters: validation or publishing workflow problems, submission-process issues, repository tooling, and documentation errors. [Report a repository issue](https://github.com/Dispatcharr/Plugins/issues/new?template=repo-issue.yml). -**2. Download the manifest** +For bugs, compatibility problems, support requests, or feature requests for a specific plugin, use that plugin's source repository or its thread in the [Dispatcharr Discord](https://discord.gg/Sp45V5BcxU). To remove a published version from the registry, [submit a Version Removal Request](https://github.com/Dispatcharr/Plugins/issues/new?template=version-removal-request.yml). -```bash -curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json -``` +## Pull request validation -**3. Verify** +GitHub Actions validates plugin submissions before they can merge. Checks cover plugin metadata, versioning, author or maintainer permission, external release details, malware scanning, and static security analysis. -```bash -jq -c '.manifest' manifest.json | gpg --verify <(jq -r '.signature' manifest.json) - -``` +For code changes, CodeQL scans submitted source. ClamAV scans submitted files and external release ZIPs. Capability-contract and sandbox-bypass findings require maintainer review. Read [plugin security](docs/plugin-security.md) for details. -A successful result looks like: +The validation workflow posts a result comment on the pull request. A pull request from an account that is not allowed to modify any affected plugin is closed automatically with instructions. -``` -gpg: Signature made ... -gpg: Good signature from "..." [full] -``` +## License -The same steps apply to any per-plugin manifest - substitute the path to `metadata//manifest.json`. +The registry code, workflows, and documentation are available under the +[GNU Affero General Public License](LICENSE), version 3 or later. Plugins under +`plugins/` remain under their own declared open source licenses. diff --git a/docs/maintainer-operations.md b/docs/maintainer-operations.md new file mode 100644 index 00000000..d8d78c81 --- /dev/null +++ b/docs/maintainer-operations.md @@ -0,0 +1,66 @@ +# Maintainer operations + +This guide covers repository settings and workflows that maintainers operate. Configure settings in **Settings → Secrets and variables → Actions**. Do not place private keys, webhook URLs, or other credentials in repository variables, workflow files, issues, or pull requests. + +## Required secrets and variables + +| Setting | Type | Used for | +| --- | --- | --- | +| `GH_APP_ID` | Variable | GitHub App ID used to mint workflow tokens. | +| `GH_APP_PRIVATE_KEY` | Secret | GitHub App private key. | +| `GPG_PRIVATE_KEY` | Secret | Armored private key used to sign published manifests. | +| `GPG_PASSPHRASE` | Secret | Passphrase for `GPG_PRIVATE_KEY`. | + +The GitHub App must be installed on this repository with the permissions needed by the workflows, including repository contents, pull requests, and issues. The shared [GitHub App token action](../.github/actions/gh-app-token/action.yml) falls back to `GITHUB_TOKEN` when the App settings are absent. That fallback cannot auto-merge or reliably trigger downstream publishing, so configure the App for normal operations. + +Generate a new manifest-signing key with: + +```bash +.github/scripts/keys/generate-signing-key.sh +``` + +Store the generated private key and passphrase only as the two repository secrets. Commit the generated public key at `.github/scripts/keys/dispatcharr-plugins.pub`, and update the public key bundled by Dispatcharr before using the new key to publish manifests. Rotate the signing key deliberately because clients use the bundled public key to verify manifests. + +## Optional settings + +| Setting | Type | Purpose | +| --- | --- | --- | +| `AUTHOR_BLACKLIST` | Variable | Comma-separated GitHub logins blocked from plugin submission validation. | +| `PLUGIN_BLACKLIST` | Variable | Comma-separated plugin slugs blocked from validation. | +| `AUTO_MERGE_UPDATES` | Variable | Set to `true` to enable eligible plugin-update auto-merges. | +| `AUTO_MERGE_BLACKLIST` | Variable | Comma-separated GitHub logins excluded from auto-merge only. | +| `AUTO_MERGE_AUTHOR_LIMIT` | Variable | Maximum automatic merges per author in the rate window. Defaults to `4`. Set to `0` or less to disable this limit. | +| `AUTO_MERGE_PLUGIN_LIMIT` | Variable | Maximum automatic merges per plugin in the rate window. Defaults to `4`. Set to `0` or less to disable this limit. | +| `AUTO_MERGE_RATE_WINDOW_HOURS` | Variable | Rolling rate-limit window in hours. Defaults to `24` and must be greater than zero. | +| `WEBHOOK_URL` | Variable | Destination for signed generic webhook events. Requires `WEBHOOK_SECRET`. | +| `WEBHOOK_SECRET` | Secret | HMAC secret for generic webhook events. Requires `WEBHOOK_URL`. | +| `DISCORD_WEBHOOKS` | Secret | JSON map of Discord webhook routes. See [webhooks](webhooks.md). | +| `DISCORD_URL` | Variable | Dispatcharr Discord URL included in validation output. | +| `EXTERNAL_README_REPO` | Variable | Target repository for the optional external README update workflow. | +| `EXTERNAL_README_PATH` | Variable | Target README path for that workflow. | +| `PR_REVIEWER` | Variable | Optional reviewer requested by the external README update workflow. | + +Webhook delivery is opt-in and best-effort. See [webhooks](webhooks.md) for event formats and Discord routing. The external README workflow skips itself when its required GitHub App and target settings are absent. + +## Auto-merge + +`Auto-merge Plugin Updates` runs after a successful `Validate Plugin` workflow. It only squash-merges a non-draft pull request when all of these conditions hold: + +1. `AUTO_MERGE_UPDATES` is exactly `true`. +2. A GitHub App token is available. +3. The pull request has the `Plugin Update` label, is cleanly mergeable, and has none of these labels: `New Plugin`, `Repo Update`, `Invalid`, `QUARANTINE`, or `Manual Review Required`. +4. Its diff changes only existing plugins under `plugins/`. +5. Its author is not in `AUTO_MERGE_BLACKLIST` and the author and plugin are within the configured rate limits. + +Every skipped candidate is marked for manual review. Successful automatic merges receive the `Auto Merged` label. Configure the repository’s branch protection and GitHub App permissions so the App can squash-merge eligible pull requests and its resulting push can start publication. + +## Maintainer workflows + +| Workflow | When to use it | Notes | +| --- | --- | --- | +| **Publish Plugins** | Publishes plugin releases after changes reach `main`, or rebuilds releases manually. | A full rebuild is destructive. Manual rebuilds require `force_rebuild_confirm` to be `CONFIRM`. Target one plugin with `force_rebuild_plugin` whenever possible. | +| **Yank Plugin Version** | Removes a published version following a Version Removal Request. | Requires the plugin slug, version, and authorizing issue number. It updates releases and closes the request. | +| **Run Migrations** | Performs maintainer-directed repository or release-branch migrations. | Review workflow inputs and logs before running it. | +| **Update External README** | Synchronizes documentation to the configured external repository. | It is inactive until the optional external-README settings are configured. | + +Before changing workflows, test, review, or signing configuration, verify that the GitHub App still has the required access and that publishing, validation, and auto-merge can create their expected labels, commits, and pull requests. diff --git a/docs/plugin-security.md b/docs/plugin-security.md new file mode 100644 index 00000000..ca380473 --- /dev/null +++ b/docs/plugin-security.md @@ -0,0 +1,69 @@ +# Plugin Security Checks + +Plugin source is checked before merge in two layers. This repository runs static +analysis while Dispatcharr applies capability checks at runtime for +`manifest_version` 2 and later. Neither layer is a security boundary: plugins +share Dispatcharr's Python interpreter, and the runtime wrappers are defense in +depth. + +## Capability Contract Queries + +The Python CodeQL pack reports direct use of selected guarded APIs when the +scanned runtime manifest is enforcing and omits the matching capability: + +| Rule | Detection | +|---|---| +| `subprocess` | `subprocess` process-launch APIs without `subprocess` | +| `network-listener` | Socket bind/listen calls without `network_listener` | +| `outbound-network` | Selected HTTP or socket connection calls without `outbound_network` | +| `background-tasks` | `context["dispatch_task"]` without `background_tasks` | +| `celery-dispatch` | `context["dispatch_internal_task"]` without `celery_dispatch` | +| `persistent-service` | `on_leader_acquired` without `persistent_service` | + +`pluginctl.capabilities` mirrors Dispatcharr's capability and manifest-version +policy. Versions 0 and 1 do not produce contract findings. Future manifest +versions use the latest known policy, as Dispatcharr does. Unknown future +capabilities remain valid declarations. For external plugins, the scan saves the +release manifest separately from catalog metadata. A catalog `plugin.json` +without a `manifest_version` is not treated as a runtime manifest. +An explicitly malformed version is retained for manual review rather than being +treated as a legacy manifest by the scanner. + +The runtime's `filesystem_write` decision also depends on whether the resolved +path is inside the plugin's code or version-stable data directory. The scanner +does not issue a declaration finding for `open` because it cannot determine that +path boundary reliably from source alone. Direct `io.open` and `os.open` are +instead reported as potential wrapper-bypass attempts for maintainer review. + +## Sandbox Bypass Queries + +The pack separately detects concrete attempts to undermine the current +plugin-local builtins and import wrappers: + +| Rule | Detection | +|---|---| +| `sys-modules-tamper` | Replacement of sensitive `sys.modules` entries | +| `ctypes-usage` | Native-code access through `ctypes` | +| `builtins-mutation` | Mutation of `__builtins__` | +| `direct-builtins-access` | Calling the original `builtins.open` or `builtins.__import__` | +| `importlib-wrapped-module` | Loading a wrapped module through `importlib.import_module` | +| `socket-convenience-api` | `socket.create_connection` outside the guarded constructor proxy | +| `requests-session-api` | `requests.Session` or `requests.session` outside guarded module helpers | +| `os-process-api` | Direct `os` process-launch APIs outside guarded `subprocess` | +| `asyncio-process-api` | Asyncio subprocess APIs outside guarded `subprocess` | +| `filesystem-api` | Direct `io`, `os`, `shutil`, or `pathlib.Path` write APIs outside guarded `open` | + +The broad frame, introspection, and dynamic-evaluation heuristics from the +earlier query pack were removed because they do not model Dispatcharr's current +`ContextVar` and plugin-local builtins design. + +Both query families are informational to required validation. A contract finding, +bypass finding, or CodeQL suppression applies `Manual Review Required`, which +blocks automatic merging until a maintainer reviews the PR. Removing the detected +code clears the label on the next validation run. + +## Suppressions + +Use an inline `codeql[rule-id]` suppression only for a justified exception. A +suppressed result still applies `Manual Review Required` and blocks automatic +merge. diff --git a/docs/verify-manifest-signatures.md b/docs/verify-manifest-signatures.md new file mode 100644 index 00000000..292040b8 --- /dev/null +++ b/docs/verify-manifest-signatures.md @@ -0,0 +1,11 @@ +# Verify manifest signatures + +Every manifest embeds a GPG signature for its compact `manifest` payload. The Dispatcharr public key is bundled with the application and is also available at `.github/scripts/keys/dispatcharr-plugins.pub` on the default branch. + +```bash +gpg --import dispatcharr-plugins.pub +curl -sO https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json +jq -c '.manifest' manifest.json | gpg --verify <(jq -r '.signature' manifest.json) - +``` + +The same process works for a per-plugin manifest from `metadata//manifest.json` on the `releases` branch. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 00000000..38658606 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,112 @@ +# Plugin registry webhooks + +`pluginctl` can emit a signed JSON event after key lifecycle points so an +external consumer (for example a Discord bot) can react to registry activity. + +Emission is **opt-in and best-effort**: + +- Configure two repository settings: `secrets.WEBHOOK_SECRET` and + `vars.WEBHOOK_URL`. +- If either is unset, emission is a silent no-op. +- A delivery failure never fails the pipeline, it logs a `::warning::` and the + workflow continues. + +## Discord Components V2 + +Discord delivery is separate from the signed generic webhook above. Configure +the `DISCORD_WEBHOOKS` **repository secret** as a JSON routing map. Webhook URLs +are credentials and must never be stored in repository variables. + +```json +{ + "releases": {"url": "https://discord.com/api/webhooks/...", "events": ["plugin.published"]}, + "maintainers": {"url": "https://discord.com/api/webhooks/...", "events": ["pr.auto_merged", "pr.review_required"]}, + "audit": {"url": "https://discord.com/api/webhooks/...", "events": ["*"]} +} +``` + +Supported events are `plugin.published`, `pr.auto_merged`, and +`pr.review_required`. Each matching route receives a Components V2 card. A +route containing `"*"` receives every supported event. + +Preview without sending: + +```bash +PYTHONPATH=.github/pluginctl/src python -m pluginctl discord-webhook --event plugin.published +PYTHONPATH=.github/pluginctl/src python -m pluginctl discord-webhook --event pr.review_required --random +``` + +Use `--send --webhook-url ` for a one-off destination, or set +`DISCORD_WEBHOOKS` for configured routes. Passing URLs on the command line can +expose their credentials in shell history, so environment configuration is +preferred. + +## Transport + +Each event is a single `POST` to `WEBHOOK_URL` with a compact JSON body and +these headers: + +| Header | Value | +|---|---| +| `Content-Type` | `application/json` | +| `X-PluginCtl-Event` | the event name (e.g. `plugin.published`) | +| `X-PluginCtl-Delivery` | a per-delivery UUIDv4 | +| `X-PluginCtl-Signature` | `sha256=` HMAC-SHA256 of the **raw request body** using `WEBHOOK_SECRET` | + +Verify the signature over the exact bytes received, before parsing JSON. + +## Envelope + +```json +{ + "event": "plugin.published", + "delivered_at": "2026-07-21T12:00:00Z", + "repository": "Dispatcharr/Plugins", + "actor": "octocat", + "data": { } +} +``` + +## Events + +| Event | `data` fields | +|---|---| +| `pr.validated` | `pr`, `author`, `result` (`pass`\|`fail`), `plugins[]`, `checks` (`{codeql,clamav,title}`) | +| `pr.closed_unauthorized` | `pr`, `author`, `reason` | +| `pr.quarantined` | `pr`, `author`, `infected` | +| `pr.auto_merged` | `pr`, `plugins[]` | +| `plugin.published` | `plugin`, `version`, `pr`, `actor` (one per changed plugin) | +| `plugin.yanked` | `plugin`, `version`, `issue`, `rollback_pr` | + +## Reference verifier + +The signature is HMAC-SHA256 of the raw body. Any language works; here is the +Python equivalent of what the consumer must do: + +```python +import hashlib +import hmac + +def verify(secret: str, raw_body: bytes, signature_header: str) -> bool: + """signature_header is the value of X-PluginCtl-Signature, e.g. 'sha256=abcd...'.""" + if not signature_header.startswith("sha256="): + return False + expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature_header[len("sha256="):]) +``` + +Node.js: + +```js +const crypto = require("crypto"); + +function verify(secret, rawBody, signatureHeader) { + if (!signatureHeader.startsWith("sha256=")) return false; + const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); + const got = signatureHeader.slice("sha256=".length); + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got)); +} +``` + +Always compare with a constant-time function (`hmac.compare_digest` / +`crypto.timingSafeEqual`).