Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/actions/gh-app-token/action.yml
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions .github/actions/trusted-checkout/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
79 changes: 79 additions & 0 deletions .github/pluginctl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 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)
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 <slug> --author <you> --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.

## 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).
43 changes: 43 additions & 0 deletions .github/pluginctl/fixtures/sample.sarif
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"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": {}}
]
},
"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}}}]
}
]
}
]
}
24 changes: 24 additions & 0 deletions .github/pluginctl/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 3 additions & 0 deletions .github/pluginctl/src/pluginctl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""pluginctl - Dispatcharr plugin registry automation CLI (V2)."""

__version__ = "2.0.0"
6 changes: 6 additions & 0 deletions .github/pluginctl/src/pluginctl/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Enable ``python -m pluginctl``."""

from .cli import main

if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading