Skip to content
Merged
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
41 changes: 41 additions & 0 deletions .github/ISSUE_TEMPLATE/plugin-unlisting-request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Plugin Unlisting Request
description: Request that a plugin be removed from registry discovery without removing its releases.
title: "[Plugin Unlisting] "
labels: ["Unlisting"]
body:
- type: markdown
attributes:
value: |
Use this form to request that a plugin be unlisted from the registry. Unlisting removes it from registry discovery and the releases README after a maintainer merges the resulting pull request. Existing releases remain available.

This request does not authorize an action by itself. Only a repository maintainer can dispatch the unlisting workflow, including for plugins they do not own or maintain.

- type: input
id: plugin
attributes:
label: Plugin slug
description: The plugin folder name (for example, `dispatcharr-exporter`).
placeholder: my-plugin
validations:
required: true

- type: dropdown
id: reason
attributes:
label: Reason for unlisting
options:
- Unsupported or abandoned
- Security or trust concern
- Replaced by another plugin
- Author request
- Other
validations:
required: true

- type: textarea
id: details
attributes:
label: Details
description: Explain why the plugin should no longer be listed and include relevant links or context.
validations:
required: true
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/version-removal-request.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
name: Version Removal Request
description: Request that a specific published version of a plugin be removed from the registry.
title: "[Version Removal] "
labels: ["Rollback"]
body:
- type: markdown
Expand Down
8 changes: 8 additions & 0 deletions .github/pluginctl/src/pluginctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,11 @@ def cmd_yank(args) -> int:
return yank.run()


def cmd_unlist(args) -> int:
from .publish import unlist
return unlist.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)
Expand Down Expand Up @@ -454,6 +459,9 @@ def build_parser() -> argparse.ArgumentParser:
p = sub.add_parser("yank", help="Yank a plugin version (env-driven)")
p.set_defaults(func=cmd_yank)

p = sub.add_parser("unlist", help="Open a PR to unlist a plugin (env-driven)")
p.set_defaults(func=cmd_unlist)

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="")
Expand Down
17 changes: 17 additions & 0 deletions .github/pluginctl/src/pluginctl/core/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ def configure_identity(app_slug: str) -> None:
run("config", "user.email", email)


def push_branch_with_lease(remote: str, branch: str) -> None:
"""Push ``HEAD`` to ``branch``, safely replacing a stale remote branch.

A fresh clone does not necessarily configure a tracking ref for a branch
created by prior automation. Resolve its remote SHA explicitly so the lease
still rejects a concurrent update.
"""
existing = run("ls-remote", "--heads", remote, branch, check=False)
destination = f"HEAD:refs/heads/{branch}"
if existing.returncode == 0 and existing.stdout.strip():
expected_sha = existing.stdout.split()[0]
run("push", f"--force-with-lease=refs/heads/{branch}:{expected_sha}",
remote, destination)
return
run("push", remote, destination)


def merge_base(a: str, b: str) -> str:
return run("merge-base", a, b).stdout.strip()

Expand Down
1 change: 1 addition & 0 deletions .github/pluginctl/src/pluginctl/label_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Label:
Label("Manual Review Required", "CFD3D7", "Security findings, suppressions, or new plugins 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("Unlisting", "E11D48", "Maintainer request to remove a plugin from registry discovery"),
Label("Auto Merged", "333333", "PR was merged automatically after validation"),
)

Expand Down
126 changes: 126 additions & 0 deletions .github/pluginctl/src/pluginctl/publish/unlist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Open a maintainer-authorized PR that removes a plugin from registry discovery."""

from __future__ import annotations

import json
import os
import re
import shutil
import tempfile

from .. import label_catalog
from ..core import actions, gh
from ..core.git import configure_identity, push_branch_with_lease, run as _git

_SAFE_PLUGIN_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")


def set_unlisted(plugin_json: str) -> bool:
"""Set ``unlisted`` to true, returning false when it is already true."""
with open(plugin_json, encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise ValueError("plugin.json must contain a JSON object")
if data.get("unlisted") is True:
return False
data["unlisted"] = True
with open(plugin_json, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
return True


def _open_pr_for_branch(repository: str, branch: str) -> str:
owner, _, _ = repository.partition("/")
if not owner:
return ""
pulls = gh.loads(gh.api(
f"repos/{repository}/pulls?state=open&head={owner}:{branch}",
))
if not isinstance(pulls, list) or not pulls:
return ""
first = pulls[0] if isinstance(pulls[0], dict) else {}
return str(first.get("html_url") or "")


def _push_branch(remote: str, branch: str) -> None:
"""Publish the branch, replacing a stale branch left by a closed request PR."""
push_branch_with_lease(remote, branch)


def run() -> int:
repository = os.environ["GITHUB_REPOSITORY"]
token = os.environ["GITHUB_TOKEN"]
plugin = os.environ["UNLIST_PLUGIN"]
issue = os.environ["UNLIST_ISSUE"]
source_branch = os.environ["SOURCE_BRANCH"]
app_slug = os.environ.get("APP_SLUG", "")
branch = f"unlist/{plugin}"
remote = f"https://x-access-token:{token}@github.com/{repository}.git"

if not _SAFE_PLUGIN_RE.fullmatch(plugin):
actions.error(f"Invalid plugin slug: {plugin!r}")
return 1
if not issue.isdigit():
actions.error(f"Invalid issue number: {issue!r}")
return 1
if not label_catalog.ensure(repository):
return 1

actions.log(f"Unlisting {plugin} (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 unlisting.")
return 1

existing_pr = _open_pr_for_branch(repository, branch)
if existing_pr:
actions.error(f"An unlisting PR for {plugin} is already open: {existing_pr}")
return 1

workdir = tempfile.mkdtemp()
start_cwd = os.getcwd()
try:
repo_dir = f"{workdir}/repo"
_git("clone", "--no-checkout", remote, repo_dir)
os.chdir(repo_dir)
configure_identity(app_slug)
_git("fetch", "origin", source_branch)
_git("checkout", "-b", branch, f"origin/{source_branch}")

plugin_json = f"plugins/{plugin}/plugin.json"
if not os.path.isfile(plugin_json):
actions.error(f"Plugin '{plugin}' does not exist on {source_branch}.")
return 1
try:
changed = set_unlisted(plugin_json)
except (OSError, ValueError, json.JSONDecodeError) as exc:
actions.error(f"Could not update {plugin_json}: {exc}")
return 1
if not changed:
actions.error(f"Plugin '{plugin}' is already unlisted.")
return 1

_git("add", plugin_json)
_git("commit", "-m", f"Unlist {plugin}\n\nRefs #{issue}")
_push_branch(remote, branch)

title = f"[{plugin}]: Unlist plugin"
body = (f"## Plugin Unlisting\n\n"
f"Sets `unlisted` to `true` for `{plugin}`, removing it from registry discovery "
f"and the releases README after this PR is merged and publishing completes. "
f"Existing releases remain available.\n\n"
f"**Authorized by:** #{issue}\n\nCloses #{issue}")
pr_url = gh.pr_create(repository, source_branch, branch, title, body,
labels=["Unlisting"])
if not pr_url:
actions.error("Unlisting branch was pushed, but the pull request could not be created.")
return 1
actions.log(f"Unlisting PR opened: {pr_url}")
return 0
finally:
os.chdir(start_cwd)
shutil.rmtree(workdir, ignore_errors=True)
86 changes: 73 additions & 13 deletions .github/pluginctl/src/pluginctl/publish/yank.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,48 @@

from .. import label_catalog
from ..core import actions, gh
from ..core.git import configure_identity, run as _git
from ..core.git import configure_identity, push_branch_with_lease, run as _git
from ..core.version import sort_versions_desc
from . import manifest, readmes

RELEASES_BRANCH = "releases"


def _open_pr_for_branch(repository: str, branch: str) -> str:
owner, _, _ = repository.partition("/")
if not owner:
return ""
pulls = gh.loads(gh.api(
f"repos/{repository}/pulls?state=open&head={owner}:{branch}",
))
if not isinstance(pulls, list) or not pulls:
return ""
first = pulls[0] if isinstance(pulls[0], dict) else {}
return str(first.get("html_url") or "")


def _needs_rollback(release_exists: bool, is_latest: bool, source_version: str,
yanked_version: str) -> bool:
"""Return whether source still advertises a version removed from releases."""
return is_latest if release_exists else source_version == yanked_version


def _is_recoverable_absent_release(manifest_has_version: bool, rollback_branch_exists: bool,
release_commit_exists: bool) -> bool:
"""Require evidence that a missing release belongs to an interrupted yank."""
return manifest_has_version or rollback_branch_exists or release_commit_exists


def _manifest_has_version(path: str, version: str) -> bool:
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
return False
versions = (data.get("manifest") or {}).get("versions") or []
return any(isinstance(entry, dict) and entry.get("version") == version for entry in versions)


def run() -> int:
repository = os.environ["GITHUB_REPOSITORY"]
token = os.environ["GITHUB_TOKEN"]
Expand Down Expand Up @@ -67,9 +102,24 @@ def run() -> int:
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
release_exists = gh.release_exists(release_tag, repository)
if not release_exists:
rollback_branch = f"yank/{plugin}-{version}"
rollback_branch_exists = _git(
"ls-remote", "--exit-code", "--heads", remote, rollback_branch,
check=False,
).returncode == 0
release_commits = _git(
"log", "--fixed-strings", f"--grep=Yank {plugin} v{version}",
"--format=%H", RELEASES_BRANCH, check=False,
)
release_commit_exists = release_commits.returncode == 0 and bool(release_commits.stdout.strip())
if not _is_recoverable_absent_release(
_manifest_has_version(plugin_manifest, version),
rollback_branch_exists, release_commit_exists):
actions.error(f"GitHub Release {release_tag} not found. Nothing to yank.")
return 1
actions.warning(f"GitHub Release {release_tag} is already absent; resuming yank recovery.")

prefix = f"{plugin}-"
remaining = [t for t in gh.release_list_tags(repository)
Expand All @@ -94,23 +144,29 @@ def run() -> int:
_git("fetch", "origin", source_branch)
_git("checkout", f"origin/{source_branch}", "--", "plugins")
source_type = "local"
source_version = ""
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")
source_data = json.load(fh)
source_type = source_data.get("source_type") or "local"
source_version = str(source_data.get("version") or "")
except ValueError:
source_type = "local"

needs_rollback = _needs_rollback(release_exists, is_latest, source_version, version)
new_latest_version = ""
if is_last_version:
actions.log(f"Last version - deleting all GitHub Releases for {plugin}.")
gh.release_delete(release_tag, repository)
if release_exists:
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:
elif release_exists:
actions.log(f"Deleting GitHub Release {release_tag}")
gh.release_delete(release_tag, repository)
if is_latest:
if not is_last_version and needs_rollback:
if is_latest or not release_exists:
bare = [t[len(prefix):] for t in remaining]
ordered = sort_versions_desc(bare)
new_latest_version = ordered[0] if ordered else ""
Expand All @@ -131,13 +187,13 @@ def run() -> int:

releases_commit = _commit_releases(remote, plugin, version, issue)
rollback_pr_url = ""
if is_latest:
if needs_rollback:
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)
needs_rollback, rollback_pr_url)
from ..integrations import webhooks
webhooks.emit("plugin.yanked",
webhooks.plugin_yanked(plugin, version, issue, rollback_pr_url or None))
Expand All @@ -163,9 +219,13 @@ def _commit_releases(remote, plugin, version, issue) -> str:


def _open_rollback_pr(repository, source_branch, remote, plugin, version,
new_latest_version, is_last_version, source_type, issue) -> str:
new_latest_version, is_last_version, source_type, issue) -> str:
actions.log("\n=== Opening rollback PR ===")
rollback_branch = f"yank/{plugin}-{version}"
existing_pr = _open_pr_for_branch(repository, rollback_branch)
if existing_pr:
actions.log(f"Rollback PR already open: {existing_pr}")
return existing_pr
_git("fetch", "origin", source_branch)
_git("checkout", "-b", rollback_branch, f"origin/{source_branch}")

Expand All @@ -187,7 +247,7 @@ def _open_rollback_pr(repository, source_branch, remote, plugin, version,
f"**Authorized by:** #{issue}\n\n{source_note}\n\nCloses #{issue}")

_git("commit", "-m", pr_title)
_git("push", remote, rollback_branch)
push_branch_with_lease(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})")
Expand Down
Loading
Loading