diff --git a/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml b/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml new file mode 100644 index 00000000..448b0b7a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/plugin-unlisting-request.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/version-removal-request.yml b/.github/ISSUE_TEMPLATE/version-removal-request.yml index 4197c3bb..ef4e719a 100644 --- a/.github/ISSUE_TEMPLATE/version-removal-request.yml +++ b/.github/ISSUE_TEMPLATE/version-removal-request.yml @@ -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 diff --git a/.github/pluginctl/src/pluginctl/cli.py b/.github/pluginctl/src/pluginctl/cli.py index 6db37ef9..8c55798e 100644 --- a/.github/pluginctl/src/pluginctl/cli.py +++ b/.github/pluginctl/src/pluginctl/cli.py @@ -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) @@ -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="") diff --git a/.github/pluginctl/src/pluginctl/core/git.py b/.github/pluginctl/src/pluginctl/core/git.py index 9c8f233b..d29d4781 100644 --- a/.github/pluginctl/src/pluginctl/core/git.py +++ b/.github/pluginctl/src/pluginctl/core/git.py @@ -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() diff --git a/.github/pluginctl/src/pluginctl/label_catalog.py b/.github/pluginctl/src/pluginctl/label_catalog.py index 2a05951b..ef4cd40f 100644 --- a/.github/pluginctl/src/pluginctl/label_catalog.py +++ b/.github/pluginctl/src/pluginctl/label_catalog.py @@ -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"), ) diff --git a/.github/pluginctl/src/pluginctl/publish/unlist.py b/.github/pluginctl/src/pluginctl/publish/unlist.py new file mode 100644 index 00000000..b069fa1c --- /dev/null +++ b/.github/pluginctl/src/pluginctl/publish/unlist.py @@ -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) diff --git a/.github/pluginctl/src/pluginctl/publish/yank.py b/.github/pluginctl/src/pluginctl/publish/yank.py index f6d20c68..ff5988f4 100644 --- a/.github/pluginctl/src/pluginctl/publish/yank.py +++ b/.github/pluginctl/src/pluginctl/publish/yank.py @@ -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"] @@ -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) @@ -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 "" @@ -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)) @@ -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}") @@ -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})") diff --git a/.github/pluginctl/src/pluginctl/validate/detect.py b/.github/pluginctl/src/pluginctl/validate/detect.py index 053c5fcc..6f0639a7 100644 --- a/.github/pluginctl/src/pluginctl/validate/detect.py +++ b/.github/pluginctl/src/pluginctl/validate/detect.py @@ -114,13 +114,20 @@ def write_access() -> bool: 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}) " + # Bot-authored maintainer-operation PRs are created by privileged workflows + # after their authorizing issue has been checked. They bypass contributor + # ownership validation and title checks, which are for user-authored PRs. + automated_operation = next( + (name for name in ("yank", "unlist") if head_ref.startswith(f"{name}/")), + "", + ) + if pr_author.endswith("[bot]") and automated_operation: + actions.log(f"Bot-authored {automated_operation} 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", + close_reason="", skip_validation="true", + skip_title_validation="true", + outside_violation="false", pub_key_changed="false", has_new_plugin="false", has_updated_plugin="false", metadata_only="false") return 0 diff --git a/.github/pluginctl/src/pluginctl/validate/gate.py b/.github/pluginctl/src/pluginctl/validate/gate.py index d2f71ec5..caa0997d 100644 --- a/.github/pluginctl/src/pluginctl/validate/gate.py +++ b/.github/pluginctl/src/pluginctl/validate/gate.py @@ -29,10 +29,10 @@ def evaluate(detect_result: str, close_pr: str, skip_validation: str, # 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 skip_validation == "true": + return GateResult(True, "No plugin changes detected and author has write access - passing.") if outside_violation == "true": return GateResult(False, "PR contains unauthorized changes outside the plugins/ directory.") if close_pr == "true": diff --git a/.github/pluginctl/tests/test_detect.py b/.github/pluginctl/tests/test_detect.py index 0029e6a7..c9023084 100644 --- a/.github/pluginctl/tests/test_detect.py +++ b/.github/pluginctl/tests/test_detect.py @@ -85,6 +85,22 @@ def test_unsafe_named_companion_folder_blocks_pr(tmp_path, monkeypatch): assert outputs["matrix"] == "[]" +@pytest.mark.parametrize("branch", ["yank/demo-1.2.3", "unlist/demo"]) +def test_bot_maintainer_operation_skips_contributor_validation(tmp_path, monkeypatch, branch): + monkeypatch.chdir(tmp_path) + out = tmp_path / "gh_output" + out.write_text("", encoding="utf-8") + monkeypatch.setenv("GITHUB_OUTPUT", str(out)) + + assert detect.run("dispatcharr-plugins-bot[bot]", "main", head_ref=branch) == 0 + + outputs = _outputs(out) + assert outputs["skip_validation"] == "true" + assert outputs["skip_title_validation"] == "true" + assert outputs["plugin_count"] == "0" + assert outputs["close_pr"] == "false" + + def test_metadata_only_update_accepts_approved_field_removal(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) pdir = tmp_path / "plugins" / "demo-plugin" diff --git a/.github/pluginctl/tests/test_git.py b/.github/pluginctl/tests/test_git.py new file mode 100644 index 00000000..e67b0ff9 --- /dev/null +++ b/.github/pluginctl/tests/test_git.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace + +from pluginctl.core import git + + +def test_push_branch_with_lease_uses_remote_sha(monkeypatch): + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + if args[0] == "ls-remote": + return SimpleNamespace(returncode=0, stdout="abc123\trefs/heads/unlist/demo\n") + return SimpleNamespace(returncode=0, stdout="") + + monkeypatch.setattr(git, "run", fake_run) + + git.push_branch_with_lease("https://example.test/repo.git", "unlist/demo") + + assert calls == [ + (("ls-remote", "--heads", "https://example.test/repo.git", "unlist/demo"), {"check": False}), + (("push", "--force-with-lease=refs/heads/unlist/demo:abc123", + "https://example.test/repo.git", "HEAD:refs/heads/unlist/demo"), {}), + ] + + +def test_push_branch_with_lease_creates_missing_branch(monkeypatch): + calls = [] + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return SimpleNamespace(returncode=0, stdout="") + + monkeypatch.setattr(git, "run", fake_run) + + git.push_branch_with_lease("https://example.test/repo.git", "unlist/demo") + + assert calls == [ + (("ls-remote", "--heads", "https://example.test/repo.git", "unlist/demo"), {"check": False}), + (("push", "https://example.test/repo.git", "HEAD:refs/heads/unlist/demo"), {}), + ] diff --git a/.github/pluginctl/tests/test_labels_gate.py b/.github/pluginctl/tests/test_labels_gate.py index 96801737..00872eed 100644 --- a/.github/pluginctl/tests/test_labels_gate.py +++ b/.github/pluginctl/tests/test_labels_gate.py @@ -54,10 +54,15 @@ def test_gate_detect_failure(): def test_gate_skip_validation_passes(): - r = gate.evaluate("success", "false", "true", "false", "", "", "", "", "", "", "") + r = gate.evaluate("success", "false", "true", "false", "success", "", "", "", "", "", "") assert r.ok +def test_gate_skip_validation_fails_for_invalid_title(): + r = gate.evaluate("success", "false", "true", "false", "failure", "", "", "", "", "", "") + assert not r.ok and "title" in r.message.lower() + + def test_gate_title_failure(): r = gate.evaluate("success", "false", "false", "false", "failure", "success", "success", "success", "success", "success", "success") @@ -78,7 +83,7 @@ def test_gate_test_suite_failure_fails_even_when_skip_validation(): def test_gate_test_suite_skipped_allows_skip_validation_pass(): - r = gate.evaluate("success", "false", "true", "false", "", "", "", "", "", "", "", + r = gate.evaluate("success", "false", "true", "false", "success", "", "", "", "", "", "", test_result="skipped") assert r.ok diff --git a/.github/pluginctl/tests/test_title.py b/.github/pluginctl/tests/test_title.py index eafda0e0..564bea25 100644 --- a/.github/pluginctl/tests/test_title.py +++ b/.github/pluginctl/tests/test_title.py @@ -6,6 +6,10 @@ def test_repo_prefix_zero_plugins_ok(): assert r.valid and r.feedback == "" +def test_repo_prefix_zero_plugins_canonical_form(): + assert check_title("[repo]: valid repository update", "alice", 0, []).valid + + def test_repo_prefix_zero_plugins_wrong(): r = check_title("[alice]: fix workflow", "alice", 0, []) assert not r.valid diff --git a/.github/pluginctl/tests/test_unlist.py b/.github/pluginctl/tests/test_unlist.py new file mode 100644 index 00000000..8fee59d0 --- /dev/null +++ b/.github/pluginctl/tests/test_unlist.py @@ -0,0 +1,81 @@ +import json + +from pluginctl import label_catalog +from pluginctl.core import gh +from pluginctl.publish import unlist + + +def test_set_unlisted_updates_only_the_flag(tmp_path): + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text(json.dumps({"name": "Demo", "version": "1.0.0"}), encoding="utf-8") + + assert unlist.set_unlisted(str(plugin_json)) + assert json.loads(plugin_json.read_text(encoding="utf-8")) == { + "name": "Demo", "version": "1.0.0", "unlisted": True, + } + assert not unlist.set_unlisted(str(plugin_json)) + + +def test_set_unlisted_rejects_non_object_json(tmp_path): + plugin_json = tmp_path / "plugin.json" + plugin_json.write_text("[]", encoding="utf-8") + + try: + unlist.set_unlisted(str(plugin_json)) + except ValueError as exc: + assert "JSON object" in str(exc) + else: + raise AssertionError("Expected a ValueError") + + +def test_open_pr_for_branch_returns_matching_pr(monkeypatch): + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: json.dumps([ + {"html_url": "https://github.com/org/repo/pull/42"}, + ])) + + assert unlist._open_pr_for_branch("org/repo", "unlist/demo") == "https://github.com/org/repo/pull/42" + + +def test_push_branch_replaces_stale_automation_branch_with_a_lease(monkeypatch): + calls = [] + monkeypatch.setattr(unlist, "push_branch_with_lease", lambda *args: calls.append(args)) + + unlist._push_branch("https://example.test/repo.git", "unlist/demo") + + assert calls == [("https://example.test/repo.git", "unlist/demo")] + + +def test_run_rejects_closed_authorizing_issue(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("UNLIST_PLUGIN", "demo") + monkeypatch.setenv("UNLIST_ISSUE", "42") + monkeypatch.setenv("SOURCE_BRANCH", "main") + monkeypatch.setattr(label_catalog, "ensure", lambda repository: True) + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: "closed") + + assert unlist.run() == 1 + + +def test_run_rejects_invalid_plugin_slug_before_api_calls(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("UNLIST_PLUGIN", "../plugin") + monkeypatch.setenv("UNLIST_ISSUE", "42") + monkeypatch.setenv("SOURCE_BRANCH", "main") + monkeypatch.setattr(label_catalog, "ensure", lambda repository: (_ for _ in ()).throw(AssertionError())) + + assert unlist.run() == 1 + + +def test_run_rejects_existing_unlisting_pr(monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.setenv("GITHUB_TOKEN", "token") + monkeypatch.setenv("UNLIST_PLUGIN", "demo") + monkeypatch.setenv("UNLIST_ISSUE", "42") + monkeypatch.setenv("SOURCE_BRANCH", "main") + monkeypatch.setattr(label_catalog, "ensure", lambda repository: True) + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: "open") + monkeypatch.setattr(unlist, "_open_pr_for_branch", lambda *args: "https://example.test/pr/42") + + assert unlist.run() == 1 diff --git a/.github/pluginctl/tests/test_yank.py b/.github/pluginctl/tests/test_yank.py new file mode 100644 index 00000000..5d0061eb --- /dev/null +++ b/.github/pluginctl/tests/test_yank.py @@ -0,0 +1,45 @@ +import json + +from pluginctl.core import gh +from pluginctl.publish import yank + + +def test_needs_rollback_for_latest_existing_release(): + assert yank._needs_rollback(True, True, "2.0.0", "2.0.0") + + +def test_needs_rollback_skips_non_latest_existing_release(): + assert not yank._needs_rollback(True, False, "2.0.0", "1.0.0") + + +def test_needs_rollback_resumes_when_source_still_advertises_absent_release(): + assert yank._needs_rollback(False, False, "2.0.0", "2.0.0") + + +def test_needs_rollback_does_not_repeat_completed_recovery(): + assert not yank._needs_rollback(False, False, "1.0.0", "2.0.0") + + +def test_absent_release_requires_interrupted_yank_evidence(): + assert not yank._is_recoverable_absent_release(False, False, False) + assert yank._is_recoverable_absent_release(True, False, False) + assert yank._is_recoverable_absent_release(False, True, False) + assert yank._is_recoverable_absent_release(False, False, True) + + +def test_manifest_has_version(tmp_path): + path = tmp_path / "manifest.json" + path.write_text(json.dumps({"manifest": {"versions": [ + {"version": "2.0.0"}, {"version": "1.0.0"}, + ]}}), encoding="utf-8") + + assert yank._manifest_has_version(str(path), "2.0.0") + assert not yank._manifest_has_version(str(path), "3.0.0") + + +def test_open_pr_for_branch_returns_matching_pr(monkeypatch): + monkeypatch.setattr(gh, "api", lambda *args, **kwargs: json.dumps([ + {"html_url": "https://github.com/org/repo/pull/42"}, + ])) + + assert yank._open_pr_for_branch("org/repo", "yank/demo-2.0.0") == "https://github.com/org/repo/pull/42" diff --git a/.github/workflows/unlist-plugin.yml b/.github/workflows/unlist-plugin.yml new file mode 100644 index 00000000..d77a1e6f --- /dev/null +++ b/.github/workflows/unlist-plugin.yml @@ -0,0 +1,71 @@ +name: Unlist Plugin + +permissions: + contents: write + pull-requests: write + issues: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONPATH: .github/pluginctl/src + +on: + workflow_dispatch: + inputs: + plugin: + description: 'Plugin slug to unlist (for example, dispatcharr-exporter)' + type: string + required: true + issue_number: + description: 'Open Plugin Unlisting Request issue number that authorizes this action (for example, 42)' + type: string + required: true + +concurrency: + group: unlist-plugin-${{ inputs.plugin }} + cancel-in-progress: false + +jobs: + unlist: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout source branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - 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: Open unlisting pull request + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + APP_SLUG: ${{ steps.token.outputs.app-slug }} + GITHUB_REPOSITORY: ${{ github.repository }} + UNLIST_PLUGIN: ${{ inputs.plugin }} + UNLIST_ISSUE: ${{ inputs.issue_number }} + SOURCE_BRANCH: ${{ github.ref_name }} + run: | + set -o pipefail + python -m pluginctl unlist 2>&1 | tee unlist.log + + - name: Generate job summary + if: always() + run: | + echo "## Plugin Unlisting Result" >> $GITHUB_STEP_SUMMARY + if [ -f unlist.log ]; then + if grep -q "Unlisting PR opened:" unlist.log; then + echo "✅ Unlisting pull request opened successfully" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Unlisting failed. See logs for details." >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -20 unlist.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/validate-plugin.yml b/.github/workflows/validate-plugin.yml index 1ad593cb..a75361f9 100644 --- a/.github/workflows/validate-plugin.yml +++ b/.github/workflows/validate-plugin.yml @@ -113,6 +113,7 @@ jobs: outside_files: ${{ steps.detect.outputs.outside_files }} outside_violation: ${{ steps.detect.outputs.outside_violation }} skip_validation: ${{ steps.detect.outputs.skip_validation }} + skip_title_validation: ${{ steps.detect.outputs.skip_title_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 }} @@ -198,7 +199,7 @@ jobs: # -------------------------------------------------------------------------- validate-title: needs: [detect-changes] - if: github.event.pull_request.draft == false && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' && needs.detect-changes.outputs.skip_validation != 'true' + if: github.event.pull_request.draft == false && needs.detect-changes.result == 'success' && needs.detect-changes.outputs.close_pr == 'false' && needs.detect-changes.outputs.skip_title_validation != 'true' runs-on: ubuntu-latest timeout-minutes: 2 outputs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cad88c8..cbaa0879 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,8 @@ Do not use it for bugs, compatibility problems, support requests, or feature req 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). +To remove a plugin from registry discovery without deleting its existing releases, [submit a Plugin Unlisting Request](https://github.com/Dispatcharr/Plugins/issues/new?template=plugin-unlisting-request.yml). A repository maintainer reviews the request and runs the unlisting workflow, which opens a pull request that sets `unlisted` to `true`. + ## Submit the pull request 1. Fork this repository and create a branch. @@ -167,3 +169,7 @@ The registry publishes a versioned ZIP and a `-latest` alias GitHub Release, cal ## Remove a published version 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. + +## Unlist a plugin + +To remove a plugin from registry discovery without removing its releases, open a [Plugin Unlisting Request](https://github.com/Dispatcharr/Plugins/issues/new?template=plugin-unlisting-request.yml). A repository maintainer can run the unlisting workflow for any plugin. It opens a pull request that sets `unlisted` to `true`; after that pull request merges and publishes, the plugin is omitted from the root manifest and releases README. Existing releases and per-plugin metadata remain available. diff --git a/docs/maintainer-operations.md b/docs/maintainer-operations.md index 43985125..fd2f3fd4 100644 --- a/docs/maintainer-operations.md +++ b/docs/maintainer-operations.md @@ -60,6 +60,7 @@ Every skipped candidate is marked for manual review. Successful automatic merges | --- | --- | --- | | **Publish Plugins** | Publishes plugin releases after changes reach `main`, rebuilds releases manually, and refreshes generated manifests every Sunday at 00:00 UTC. | A full rebuild is destructive. Manual rebuilds require `force_rebuild_confirm` to be `CONFIRM`. Target one plugin with `force_rebuild_plugin` whenever possible. The scheduled run can publish updated GitHub Release download totals when that manifest feature flag is enabled. | | **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. | +| **Unlist Plugin** | Removes a plugin from registry discovery following a Plugin Unlisting Request. | Requires the plugin slug and an open authorizing issue number. It opens a PR that sets `unlisted` to `true`; merging the PR triggers normal publication and closes the request. Existing releases remain available. | | **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. |