diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc0d543 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + + - name: Test catalog tooling + env: + GOWORK: off + run: go test ./... + + - name: Validate approved catalog + env: + GOWORK: off + run: go run ./cmd/check-catalog diff --git a/README.md b/README.md index 347b1bf..487382d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,13 @@ release workflow dispatches `plugin_release_published` to this repository. The catalog updater verifies the repository and plugin ID against the approval registry before updating `manifest.json`. +Validate the registry and generated catalog locally with: + +```sh +GOWORK=off go test ./... +GOWORK=off go run ./cmd/check-catalog +``` + ## License The catalog tooling is licensed under `Apache-2.0`. See [LICENSE](LICENSE). diff --git a/approved-plugins.json b/approved-plugins.json index a5b818f..c27ceba 100644 --- a/approved-plugins.json +++ b/approved-plugins.json @@ -1,3 +1,16 @@ { - "plugins": [] + "plugins": [ + { + "plugin_id": "silo.requests.arr", + "repository": "Silo-Community/silo-plugins-requests-arr", + "approved_at": "2026-07-09", + "review_url": "https://github.com/Silo-Community/silo-plugins/issues/2" + }, + { + "plugin_id": "silo.requests.seerr", + "repository": "Silo-Community/silo-plugins-requests-seerr", + "approved_at": "2026-07-09", + "review_url": "https://github.com/Silo-Community/silo-plugins/issues/1" + } + ] } diff --git a/catalog/approval.go b/catalog/approval.go index 669084f..44ff4d0 100644 --- a/catalog/approval.go +++ b/catalog/approval.go @@ -71,6 +71,32 @@ func (r ApprovalRegistry) Resolve(repository, pluginID string) (Approval, error) return Approval{}, fmt.Errorf("plugin %s from %s is not approved", pluginID, repository) } +func ValidateApprovedIndex(index RepositoryIndex, registry ApprovalRegistry) error { + seenPluginIDs := make(map[string]struct{}, len(index.Plugins)) + for _, pkg := range index.Plugins { + if pkg.Manifest == nil { + return fmt.Errorf("catalog package manifest is required") + } + pluginID := pkg.Manifest.GetPluginId() + if _, exists := seenPluginIDs[pluginID]; exists { + return fmt.Errorf("catalog plugin_id %q is duplicated", pluginID) + } + seenPluginIDs[pluginID] = struct{}{} + repository := strings.TrimPrefix(pkg.RepoURL, "https://github.com/") + approval, err := registry.Resolve(repository, pluginID) + if err != nil { + return err + } + if pkg.Approval == nil { + return fmt.Errorf("catalog plugin %s is missing approval metadata", pluginID) + } + if *pkg.Approval != approval { + return fmt.Errorf("catalog plugin %s approval metadata does not match the registry", pluginID) + } + } + return nil +} + func validateGitHubURL(raw string) error { parsed, err := url.Parse(raw) if err != nil { diff --git a/catalog/approval_test.go b/catalog/approval_test.go index eb1d462..4537538 100644 --- a/catalog/approval_test.go +++ b/catalog/approval_test.go @@ -67,3 +67,24 @@ func TestPruneUnapproved(t *testing.T) { t.Fatal("approved package is missing approval metadata") } } + +func TestValidateApprovedIndexRejectsMissingApprovalMetadata(t *testing.T) { + registry, err := DecodeApprovalRegistry([]byte(`{ + "plugins": [{ + "plugin_id": "silo.requests.arr", + "repository": "Silo-Community/silo-plugins-requests-arr", + "approved_at": "2026-07-09", + "review_url": "https://github.com/Silo-Community/silo-plugins/issues/1" + }] +}`)) + if err != nil { + t.Fatalf("DecodeApprovalRegistry() error = %v", err) + } + index := RepositoryIndex{Plugins: []CatalogPackage{{ + Manifest: &SourceManifest{PluginId: "silo.requests.arr"}, + RepoURL: "https://github.com/Silo-Community/silo-plugins-requests-arr", + }}} + if err := ValidateApprovedIndex(index, registry); err == nil { + t.Fatal("ValidateApprovedIndex() unexpectedly succeeded") + } +} diff --git a/cmd/check-catalog/main.go b/cmd/check-catalog/main.go new file mode 100644 index 0000000..3b036f1 --- /dev/null +++ b/cmd/check-catalog/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/Silo-Community/silo-plugins/catalog" +) + +func main() { + var manifestPath string + var approvalsPath string + flag.StringVar(&manifestPath, "manifest", "manifest.json", "Path to the catalog manifest") + flag.StringVar(&approvalsPath, "approvals", "approved-plugins.json", "Path to the approved plugin registry") + flag.Parse() + + approvalsData, err := os.ReadFile(approvalsPath) + if err != nil { + exitf("read approvals: %v", err) + } + registry, err := catalog.DecodeApprovalRegistry(approvalsData) + if err != nil { + exitf("decode approvals: %v", err) + } + + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + exitf("read catalog: %v", err) + } + var index catalog.RepositoryIndex + if err := json.Unmarshal(manifestData, &index); err != nil { + exitf("decode catalog: %v", err) + } + if err := catalog.ValidateApprovedIndex(index, registry); err != nil { + exitf("validate catalog: %v", err) + } +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/manifest.json b/manifest.json index a5b818f..4334c3e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,3 +1,388 @@ { - "plugins": [] + "plugins": [ + { + "manifest": { + "plugin_id": "silo.requests.arr", + "version": "0.1.0", + "silo_api_version": "v1", + "supported_platforms": [ + { + "os": "linux", + "arch": "amd64" + }, + { + "os": "linux", + "arch": "arm64" + }, + { + "os": "darwin", + "arch": "arm64" + } + ], + "capabilities": [ + { + "type": "request_router.v1", + "id": "arr", + "display_name": "Sonarr / Radarr", + "description": "Fulfills movie/series requests against multi-instance Sonarr/Radarr.", + "config_schema": [ + { + "key": "connection", + "title": "Connection", + "json_schema": "{\"type\":\"object\",\"properties\":{\"service_kind\":{\"type\":\"string\"},\"root_folder\":{\"type\":\"string\"},\"quality_profile_id\":{\"type\":\"integer\"},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}},\"is_default\":{\"type\":\"boolean\"},\"is_4k\":{\"type\":\"boolean\"},\"is_default_4k\":{\"type\":\"boolean\"},\"search_on_add\":{\"type\":\"boolean\"},\"minimum_availability\":{\"type\":\"string\"},\"series_type\":{\"type\":\"string\"},\"season_folder\":{\"type\":\"boolean\"},\"anime_enabled\":{\"type\":\"boolean\"},\"anime_root_folder\":{\"type\":\"string\"},\"anime_quality_profile_id\":{\"type\":\"integer\"},\"anime_tags\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}}", + "admin_form": { + "fields": [ + { + "key": "service_kind", + "label": "Service", + "control": 6, + "required": true, + "options": [ + { + "value": "radarr", + "label": "Radarr (movies)" + }, + { + "value": "sonarr", + "label": "Sonarr (series)" + } + ] + }, + { + "key": "root_folder", + "label": "Root folder", + "control": 6, + "dynamic_options": true + }, + { + "key": "quality_profile_id", + "label": "Quality profile", + "control": 6, + "required": true, + "dynamic_options": true + }, + { + "key": "tags", + "label": "Tags", + "control": 7, + "dynamic_options": true + }, + { + "key": "is_default", + "label": "Default (HD/1080p)", + "control": 5, + "exclusive_group_field": "service_kind" + }, + { + "key": "is_4k", + "label": "4K instance", + "control": 5 + }, + { + "key": "is_default_4k", + "label": "Default 4K (2160p)", + "control": 5, + "show_when": [ + { + "field": "is_4k", + "equals": [ + "true" + ] + } + ], + "exclusive_group_field": "service_kind" + }, + { + "key": "search_on_add", + "label": "Search on add", + "control": 5, + "default_value": true + }, + { + "key": "minimum_availability", + "label": "Minimum availability", + "control": 6, + "default_value": "released", + "options": [ + { + "value": "announced", + "label": "Announced" + }, + { + "value": "inCinemas", + "label": "In cinemas" + }, + { + "value": "released", + "label": "Released" + } + ], + "show_when": [ + { + "field": "service_kind", + "equals": [ + "radarr" + ] + } + ] + }, + { + "key": "series_type", + "label": "Series type", + "control": 6, + "default_value": "standard", + "options": [ + { + "value": "standard", + "label": "Standard" + }, + { + "value": "daily", + "label": "Daily" + }, + { + "value": "anime", + "label": "Anime" + } + ], + "show_when": [ + { + "field": "service_kind", + "equals": [ + "sonarr" + ] + } + ] + }, + { + "key": "season_folder", + "label": "Season folder", + "control": 5, + "default_value": true, + "show_when": [ + { + "field": "service_kind", + "equals": [ + "sonarr" + ] + } + ] + }, + { + "key": "anime_enabled", + "label": "Enable anime overrides", + "control": 5 + }, + { + "key": "anime_root_folder", + "label": "Anime root folder", + "control": 6, + "dynamic_options": true, + "show_when": [ + { + "field": "anime_enabled", + "equals": [ + "true" + ] + } + ] + }, + { + "key": "anime_quality_profile_id", + "label": "Anime quality profile", + "control": 6, + "dynamic_options": true, + "show_when": [ + { + "field": "anime_enabled", + "equals": [ + "true" + ] + } + ] + }, + { + "key": "anime_tags", + "label": "Anime tags", + "control": 7, + "dynamic_options": true, + "show_when": [ + { + "field": "anime_enabled", + "equals": [ + "true" + ] + } + ] + } + ], + "submit_label": "Save connection", + "sections": [ + { + "key": "library", + "title": "Library", + "collapsible": true, + "collapsed_default": true, + "field_keys": [ + "service_kind", + "root_folder", + "quality_profile_id", + "tags", + "is_default", + "is_4k", + "is_default_4k", + "search_on_add", + "minimum_availability", + "series_type", + "season_folder" + ] + }, + { + "key": "anime", + "title": "Anime overrides", + "field_keys": [ + "anime_enabled", + "anime_root_folder", + "anime_quality_profile_id", + "anime_tags" + ] + } + ] + } + } + ] + } + ] + }, + "repo_url": "https://github.com/Silo-Community/silo-plugins-requests-arr", + "checksums_url": "https://github.com/Silo-Community/silo-plugins-requests-arr/releases/download/v0.1.0/checksums.txt", + "binaries": { + "darwin/arm64": { + "url": "https://github.com/Silo-Community/silo-plugins-requests-arr/releases/download/v0.1.0/plugin-darwin-arm64" + }, + "linux/amd64": { + "url": "https://github.com/Silo-Community/silo-plugins-requests-arr/releases/download/v0.1.0/plugin-linux-amd64" + }, + "linux/arm64": { + "url": "https://github.com/Silo-Community/silo-plugins-requests-arr/releases/download/v0.1.0/plugin-linux-arm64" + } + }, + "approval": { + "approved_at": "2026-07-09", + "review_url": "https://github.com/Silo-Community/silo-plugins/issues/2" + } + }, + { + "manifest": { + "plugin_id": "silo.requests.seerr", + "version": "0.1.0", + "silo_api_version": "v1", + "supported_platforms": [ + { + "os": "linux", + "arch": "amd64" + }, + { + "os": "linux", + "arch": "arm64" + }, + { + "os": "darwin", + "arch": "arm64" + } + ], + "capabilities": [ + { + "type": "request_router.v1", + "id": "seerr", + "display_name": "Seerr", + "description": "Fulfill content requests through a Seerr (Overseerr/Jellyseerr) instance.", + "config_schema": [ + { + "key": "connection", + "title": "Seerr", + "json_schema": "{\"type\":\"object\",\"properties\":{\"supports_4k\":{\"type\":\"boolean\"},\"requester_mode\":{\"type\":\"string\"},\"auto_approve\":{\"type\":\"boolean\"},\"require_mapped_user\":{\"type\":\"boolean\"}}}", + "admin_form": { + "fields": [ + { + "key": "supports_4k", + "label": "This Seerr handles 4K requests", + "description": "Enable only if the Seerr instance has a 4K Sonarr/Radarr configured. When off, 2160p requests are not sent to this connection.", + "control": 5, + "default_value": false + }, + { + "key": "requester_mode", + "label": "Attribute requests to", + "description": "Admin user: all requests come from the API-key owner (auto-approved). Map to Silo users: attribute each request to the Seerr user matching the requester's email (created if missing).", + "control": 6, + "default_value": "admin", + "options": [ + { + "value": "admin", + "label": "Admin user (API key owner)" + }, + { + "value": "mapped", + "label": "Map to Silo users" + } + ] + }, + { + "key": "auto_approve", + "label": "Auto-approve requests", + "description": "Mapped users' requests are auto-approved. Off: they land in Seerr's per-user approval queue.", + "control": 5, + "default_value": true, + "show_when": [ + { + "field": "requester_mode", + "equals": [ + "mapped" + ] + } + ] + }, + { + "key": "require_mapped_user", + "label": "Fail the request if the user can't be mapped", + "description": "On: a request whose Silo user can't be matched/created on Seerr fails instead of being submitted under the admin.", + "control": 5, + "default_value": false, + "show_when": [ + { + "field": "requester_mode", + "equals": [ + "mapped" + ] + } + ] + } + ], + "submit_label": "Save connection" + } + } + ] + } + ] + }, + "repo_url": "https://github.com/Silo-Community/silo-plugins-requests-seerr", + "checksums_url": "https://github.com/Silo-Community/silo-plugins-requests-seerr/releases/download/v0.1.0/checksums.txt", + "binaries": { + "darwin/arm64": { + "url": "https://github.com/Silo-Community/silo-plugins-requests-seerr/releases/download/v0.1.0/plugin-darwin-arm64" + }, + "linux/amd64": { + "url": "https://github.com/Silo-Community/silo-plugins-requests-seerr/releases/download/v0.1.0/plugin-linux-amd64" + }, + "linux/arm64": { + "url": "https://github.com/Silo-Community/silo-plugins-requests-seerr/releases/download/v0.1.0/plugin-linux-arm64" + } + }, + "approval": { + "approved_at": "2026-07-09", + "review_url": "https://github.com/Silo-Community/silo-plugins/issues/1" + } + } + ] }