diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7914492..57010a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,16 +17,33 @@ env: COVER_MIN: "95" jobs: + manifest: + name: Validate catalog manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Parse manifest.json + run: python3 -m json.tool manifest.json > /dev/null + + # Offline checks only: well-formed https release-asset URLs in each + # entry's own repo, under its version tag. No HEAD requests, so a + # release that was never published still has to be caught by review. + - name: Check catalog download URLs + run: python3 scripts/validate-manifest.py manifest.json + lint: name: Go lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true @@ -38,9 +55,9 @@ jobs: go env -w GONOSUMDB=github.com/prairie-server/* - name: golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: latest + version: v2.14.0 only-new-issues: true args: --timeout=5m @@ -48,11 +65,11 @@ jobs: name: Go tests + coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod cache: true @@ -83,7 +100,7 @@ jobs: - name: Upload coverage profile if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-out path: coverage.out diff --git a/.github/workflows/update-catalog.yml b/.github/workflows/update-catalog.yml index 7b2cbd2..4ff3da0 100644 --- a/.github/workflows/update-catalog.yml +++ b/.github/workflows/update-catalog.yml @@ -26,11 +26,11 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.CATALOG_PUSH_TOKEN }} - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.26" diff --git a/.github/workflows/update-manifest.yml b/.github/workflows/update-manifest.yml index 278f3e6..1ce2c86 100644 --- a/.github/workflows/update-manifest.yml +++ b/.github/workflows/update-manifest.yml @@ -10,8 +10,6 @@ on: options: - audiobook-metadata - autoscan-arr - - requests-arr - - requests-seerr - sportarr - tmdb - tvdb @@ -30,11 +28,11 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.CATALOG_PUSH_TOKEN }} - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.26" @@ -47,8 +45,6 @@ jobs: case "${PLUGIN}" in audiobook-metadata) REPO="prairie-server/prairie-plugin-metadata-audiobook" ;; autoscan-arr) REPO="prairie-server/prairie-plugin-autoscan-arr" ;; - requests-arr) REPO="prairie-server/prairie-plugins-requests-arr" ;; - requests-seerr) REPO="prairie-server/prairie-plugins-requests-seerr" ;; sportarr) REPO="prairie-server/prairie-plugin-metadata-sportarr" ;; tmdb) REPO="prairie-server/prairie-plugin-metadata-tmdb" ;; tvdb) REPO="prairie-server/prairie-plugin-metadata-tvdb" ;; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5fa30c..844f4a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,19 @@ go test ./... go vet ./... go build ./... gofmt -l . +golangci-lint run ./... +go test $(go list ./... | grep -v '/cmd/') -count=1 -covermode=atomic -coverprofile=coverage.out +./scripts/check-coverage.sh coverage.out +python3 scripts/validate-manifest.py manifest.json ``` +CI runs golangci-lint v2.14.0, enforces a 95% statement coverage floor +(`scripts/check-coverage.sh`), and checks that `manifest.json` parses and that +every download URL is a well-formed release-asset URL for the entry's own +repository and version tag. The last four commands reproduce those checks. +`validate-manifest.py` makes no network requests, so it cannot tell whether a +release actually exists. + `gofmt -l .` should print nothing. If it reports unrelated pre-existing drift, none of the Go files touched by your change may appear in the output; do not add to the output, and report what remains. For catalog output changes, inspect the diff --git a/cmd/update-catalog/main.go b/cmd/update-catalog/main.go index 7555bea..141ee78 100644 --- a/cmd/update-catalog/main.go +++ b/cmd/update-catalog/main.go @@ -86,7 +86,7 @@ func fetchSourceManifest(ctx context.Context, client *http.Client, token, repo, if err != nil { return nil, fmt.Errorf("GET %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) return nil, fmt.Errorf("GET %s: status %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body))) @@ -118,7 +118,7 @@ func githubJSON(ctx context.Context, client *http.Client, token, url string, des if err != nil { return fmt.Errorf("GET %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) return fmt.Errorf("GET %s: status %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body))) diff --git a/go.mod b/go.mod index 31bbc70..6b3a502 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,9 @@ require ( ) require ( - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/grpc v1.82.1 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.2 // indirect ) diff --git a/go.sum b/go.sum index beb057d..54bd623 100644 --- a/go.sum +++ b/go.sum @@ -14,27 +14,27 @@ github.com/prairie-server/prairie-plugin-sdk v0.12.1-0.20260928142658-1b20b2f74c github.com/prairie-server/prairie-plugin-sdk v0.12.1-0.20260928142658-1b20b2f74c42/go.mod h1:P4QeyZ1jFOPjGcPHJ/kMH47QKgeLOCy4z2A6g1CYgkE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/manifest.json b/manifest.json index 047837c..1401f46 100644 --- a/manifest.json +++ b/manifest.json @@ -478,382 +478,6 @@ } } }, - { - "manifest": { - "plugin_id": "prairie.requests.arr", - "version": "0.1.0", - "prairie_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/prairie-server/prairie-plugins-requests-arr", - "checksums_url": "https://github.com/prairie-server/prairie-plugins-requests-arr/releases/download/v0.1.0/checksums.txt", - "binaries": { - "darwin/arm64": { - "url": "https://github.com/prairie-server/prairie-plugins-requests-arr/releases/download/v0.1.0/plugin-darwin-arm64" - }, - "linux/amd64": { - "url": "https://github.com/prairie-server/prairie-plugins-requests-arr/releases/download/v0.1.0/plugin-linux-amd64" - }, - "linux/arm64": { - "url": "https://github.com/prairie-server/prairie-plugins-requests-arr/releases/download/v0.1.0/plugin-linux-arm64" - } - } - }, - { - "manifest": { - "plugin_id": "prairie.requests.seerr", - "version": "0.1.0", - "prairie_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 Prairie 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 Prairie 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 Prairie 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/prairie-server/prairie-plugins-requests-seerr", - "checksums_url": "https://github.com/prairie-server/prairie-plugins-requests-seerr/releases/download/v0.1.0/checksums.txt", - "binaries": { - "darwin/arm64": { - "url": "https://github.com/prairie-server/prairie-plugins-requests-seerr/releases/download/v0.1.0/plugin-darwin-arm64" - }, - "linux/amd64": { - "url": "https://github.com/prairie-server/prairie-plugins-requests-seerr/releases/download/v0.1.0/plugin-linux-amd64" - }, - "linux/arm64": { - "url": "https://github.com/prairie-server/prairie-plugins-requests-seerr/releases/download/v0.1.0/plugin-linux-arm64" - } - } - }, { "manifest": { "plugin_id": "prairie.sportarr", diff --git a/scripts/validate-manifest.py b/scripts/validate-manifest.py new file mode 100755 index 0000000..1d9d500 --- /dev/null +++ b/scripts/validate-manifest.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Validate the published plugin catalog (manifest.json) without network access. + +Checks that the file parses and that every entry's download URLs are +well-formed: https GitHub release-asset URLs in the entry's own repository, +under the tag for the entry's version, with one binary per supported platform. +It does not fetch anything, so it cannot tell whether a release really exists. + +Usage: scripts/validate-manifest.py [manifest.json] +""" +import json +import re +import sys +from urllib.parse import urlsplit + +SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") +PLATFORM = re.compile(r"^[a-z0-9]+/[a-z0-9]+$") +REPO_PATH = re.compile(r"^/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$") +ASSET_PATH = re.compile(r"^/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/releases/download/([^/]+)/([^/]+)$") + + +def check_url(value, where, errors): + """Return the parsed URL if value is an absolute https URL, else record an error.""" + if not isinstance(value, str) or not value: + errors.append(f"{where}: missing or not a string") + return None + parts = urlsplit(value) + if parts.scheme != "https" or not parts.netloc: + errors.append(f"{where}: not an absolute https URL: {value!r}") + return None + if parts.username or parts.password or parts.query or parts.fragment or any(c.isspace() for c in value): + errors.append(f"{where}: must not carry credentials, a query, a fragment, or whitespace: {value!r}") + return None + return parts + + +def check_asset(value, where, repo, tag, filename, errors): + parts = check_url(value, where, errors) + if parts is None: + return + m = ASSET_PATH.match(parts.path) + if parts.netloc != "github.com" or not m: + errors.append(f"{where}: not a GitHub release-asset URL: {value!r}") + return + owner, name, url_tag, url_file = m.groups() + if repo and (owner.lower(), name.lower()) != repo: + errors.append(f"{where}: points at {owner}/{name}, but repo_url is {'/'.join(repo)}") + if url_tag != tag: + errors.append(f"{where}: release tag {url_tag!r} does not match version tag {tag!r}") + if url_file != filename: + errors.append(f"{where}: asset {url_file!r}, want {filename!r}") + + +def validate(doc): + errors = [] + if not isinstance(doc, dict) or not isinstance(doc.get("plugins"), list) or not doc["plugins"]: + return ["top level must be an object with a non-empty \"plugins\" array"] + seen = set() + for i, entry in enumerate(doc["plugins"]): + manifest = entry.get("manifest") if isinstance(entry, dict) else None + if not isinstance(manifest, dict): + errors.append(f"plugins[{i}]: missing \"manifest\" object") + continue + pid = manifest.get("plugin_id") + where = f"plugins[{i}] ({pid})" + if not isinstance(pid, str) or not pid: + errors.append(f"{where}: missing manifest.plugin_id") + elif pid in seen: + errors.append(f"{where}: duplicate plugin_id") + seen.add(pid) + version = manifest.get("version") + if not isinstance(version, str) or not SEMVER.match(version): + errors.append(f"{where}: manifest.version {version!r} is not semver") + version = None + tag = f"v{version}" if version else None + + repo = None + parts = check_url(entry.get("repo_url"), f"{where}.repo_url", errors) + if parts is not None: + m = REPO_PATH.match(parts.path) + if parts.netloc != "github.com" or not m: + errors.append(f"{where}.repo_url: not a https://github.com// URL") + else: + repo = (m.group(1).lower(), m.group(2).lower()) + + check_asset(entry.get("checksums_url"), f"{where}.checksums_url", repo, tag, "checksums.txt", errors) + + binaries = entry.get("binaries") + if not isinstance(binaries, dict) or not binaries: + errors.append(f"{where}: missing \"binaries\" object") + binaries = {} + for platform, binary in binaries.items(): + bwhere = f"{where}.binaries[{platform!r}]" + if not PLATFORM.match(platform): + errors.append(f"{bwhere}: platform key must be os/arch") + continue + url = binary.get("url") if isinstance(binary, dict) else None + check_asset(url, f"{bwhere}.url", repo, tag, "plugin-" + platform.replace("/", "-"), errors) + + platforms = manifest.get("supported_platforms") or [] + declared = {f"{p.get('os')}/{p.get('arch')}" for p in platforms if isinstance(p, dict)} + if declared != set(binaries): + errors.append(f"{where}: supported_platforms {sorted(declared)} != binaries {sorted(binaries)}") + + presentation = manifest.get("presentation") or {} + for key, value in presentation.items(): + if key.endswith("_url") and value: + check_url(value, f"{where}.manifest.presentation.{key}", errors) + return errors + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "manifest.json" + try: + with open(path, encoding="utf-8") as f: + doc = json.load(f) + except (OSError, ValueError) as err: + print(f"{path}: does not parse: {err}", file=sys.stderr) + return 1 + errors = validate(doc) + for err in errors: + print(f"{path}: {err}", file=sys.stderr) + if errors: + return 1 + print(f"{path}: {len(doc['plugins'])} plugin(s) OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main())