From 4b827941eed04c5eccb995d8713a81f18da01426 Mon Sep 17 00:00:00 2001 From: Pascal Linxweiler Date: Wed, 29 Jul 2026 15:18:40 +0200 Subject: [PATCH] fix: make the release workflow build and package a real Jellyfin plugin The release workflow was copied from another project and could not succeed on any tag: it installed the .NET 8 SDK for a net9.0 project, published a JellyfinSso.csproj that does not exist, called a missing update_manifest.py, and zipped the entire publish tree instead of the three DLLs declared in build.yaml. Replace it with a tag-driven release: - Build with the .NET 9 SDK, injecting the tag version via -p:Version, -p:AssemblyVersion and -p:FileVersion so the assembly no longer carries a hardcoded 1.0.3.0. - Package via scripts/build_plugin.py, which emits the JPRM layout Jellyfin expects: the build.yaml artifacts plus a generated meta.json. The previous zips shipped without meta.json, which only worked for repository installs because Jellyfin synthesises one from the folder name. - Update manifest.json via scripts/update_manifest.py, keeping build.yaml as the single source of truth for plugin metadata. - Commit the zip and manifest to main and attach the zip to the Gitea release. Pushing uses the injected token, falling back to a RELEASE_TOKEN secret when the built-in one lacks write access. Also fix the published download URLs. manifest.json and the README pointed at pascallinxweiler/jellyfinplugin, which 404s; the repository is jellyfinsso, so the plugin repository could not be added to Jellyfin and no version could be downloaded. The workflow now derives the URL from GITHUB_REPOSITORY. Add a build workflow so main and pull requests are compiled and packaged. Co-Authored-By: Claude --- .gitea/workflows/build.yml | 42 ++++ .gitea/workflows/release.yml | 127 ++++++++---- .../Jellyfin.Plugin.OidcAuth.csproj | 3 + README.md | 33 ++- build.yaml | 4 +- manifest.json | 6 +- scripts/build_plugin.py | 192 ++++++++++++++++++ scripts/update_manifest.py | 113 +++++++++++ 8 files changed, 476 insertions(+), 44 deletions(-) create mode 100644 .gitea/workflows/build.yml create mode 100755 scripts/build_plugin.py create mode 100755 scripts/update_manifest.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..ecffde3 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,42 @@ +name: Build Plugin + +on: + push: + branches: + - main + paths-ignore: + - '**/*.md' + pull_request: + paths-ignore: + - '**/*.md' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install packaging dependencies + run: python -m pip install --quiet --upgrade pyyaml + + - name: Build and package plugin + id: package + run: python scripts/build_plugin.py --output "$RUNNER_TEMP/dist" + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ steps.package.outputs.zip_name }} + path: ${{ runner.temp }}/dist/${{ steps.package.outputs.zip_name }} diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index df6a083..5f50e6a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -6,10 +6,10 @@ on: - 'v*' jobs: - build-and-release: + release: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout tag uses: actions/checkout@v4 with: fetch-depth: 0 @@ -17,44 +17,99 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '9.0.x' - - name: Extract version from tag - id: version - run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Publish - run: dotnet publish JellyfinSso.csproj --configuration Release --output publish - - - name: Zip build output - run: | - cd publish - zip -r "../jellyfinsso-${{ steps.version.outputs.version }}.zip" . - - - name: Compute checksum - id: checksum - run: | - CHECKSUM=$(md5sum "jellyfinsso-${{ steps.version.outputs.version }}.zip" | cut -d' ' -f1) - echo "md5=$CHECKSUM" >> "$GITHUB_OUTPUT" - - - name: Upload zip to Gitea Release - uses: akkuman/gitea-release-action@v1 + - name: Setup Python + uses: actions/setup-python@v5 with: - files: "jellyfinsso-${{ steps.version.outputs.version }}.zip" + python-version: '3.x' - - name: Update manifest.json - run: | - python3 update_manifest.py \ - "${{ steps.version.outputs.version }}" \ - "${{ steps.checksum.outputs.md5 }}" \ - "https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/releases/download/${GITHUB_REF_NAME}/jellyfinsso-${{ steps.version.outputs.version }}.zip" + - name: Install packaging dependencies + run: python -m pip install --quiet --upgrade pyyaml - - name: Commit manifest.json + - name: Read version and changelog from tag + id: meta run: | + set -euo pipefail + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + # Annotated tag message becomes the changelog; build.yaml is the fallback. + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Build and package plugin + id: package + env: + VERSION: ${{ steps.meta.outputs.version }} + CHANGELOG: ${{ steps.meta.outputs.changelog }} + run: | + set -euo pipefail + python scripts/build_plugin.py \ + --version "$VERSION" \ + --changelog "$CHANGELOG" \ + --output "$RUNNER_TEMP/dist" + + - name: Switch to main + run: | + set -euo pipefail + git checkout -- . + git fetch origin main + git checkout -B main origin/main + + - name: Update dist, build.yaml and manifest.json + env: + VERSION: ${{ steps.package.outputs.version }} + ZIP_NAME: ${{ steps.package.outputs.zip_name }} + CHECKSUM: ${{ steps.package.outputs.checksum }} + CHANGELOG: ${{ steps.meta.outputs.changelog }} + run: | + set -euo pipefail + mkdir -p dist + cp "$RUNNER_TEMP/dist/$ZIP_NAME" "dist/$ZIP_NAME" + + python scripts/build_plugin.py --version "$VERSION" --sync-only + + python scripts/update_manifest.py \ + --version "$VERSION" \ + --checksum "$CHECKSUM" \ + --changelog "$CHANGELOG" \ + --source-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/raw/branch/main/dist/${ZIP_NAME}" + + - name: Commit and push to main + env: + # Gitea injects GITHUB_TOKEN; set RELEASE_TOKEN if that token does not + # have write access to the repository. + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + DEFAULT_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TOKEN="${RELEASE_TOKEN:-$DEFAULT_TOKEN}" + if [ -z "$TOKEN" ]; then + echo "no token available to push manifest.json" >&2 + exit 1 + fi + git config user.name "gitea-actions" git config user.email "actions@git.linxweiler.xyz" - git add manifest.json - git commit -m "Update manifest.json for ${GITHUB_REF_NAME}" || echo "No changes" - git push origin HEAD:main \ No newline at end of file + git add dist manifest.json build.yaml Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj + + if git diff --cached --quiet; then + echo "nothing to commit" + exit 0 + fi + + git commit -m "release: ${GITHUB_REF_NAME}" + + git remote set-url origin \ + "https://x-access-token:${TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" + git push origin main + + - name: Attach zip to Gitea release + uses: akkuman/gitea-release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + files: dist/${{ steps.package.outputs.zip_name }} + body: ${{ steps.meta.outputs.changelog }} diff --git a/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj b/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj index 14be42a..944968b 100644 --- a/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj +++ b/Jellyfin.Plugin.OidcAuth/Jellyfin.Plugin.OidcAuth.csproj @@ -3,6 +3,9 @@ net9.0 Jellyfin.Plugin.OidcAuth + + 1.0.3.0 1.0.3.0 1.0.3.0 enable diff --git a/README.md b/README.md index bbd9354..57de054 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,41 @@ Log in to Jellyfin with any OpenID Connect provider (Authelia, Authentik, Keyclo ## Build +Requires the .NET 9 SDK and `pyyaml`. The script compiles the plugin and packages +it the way Jellyfin expects — the DLLs listed in `build.yaml` plus a generated +`meta.json` — into `dist/oidc-auth_.zip`: + ```bash -dotnet publish Jellyfin.Plugin.OidcAuth --configuration Release --output artifacts +pip install pyyaml +python scripts/build_plugin.py ``` +Pass `--version 1.0.4.0` to override the version in `build.yaml`. + +## Release + +Releases are cut by pushing an annotated tag; the tag message becomes the changelog: + +```bash +git tag -a v1.0.4.0 -m "What changed in this release." +git push origin v1.0.4.0 +``` + +`.gitea/workflows/release.yml` then builds the plugin at that tag, commits the zip +to `dist/` on `main` together with an updated `manifest.json`, and attaches the zip +to the Gitea release. Jellyfin picks the new version up from the repository URL below. + +Pushing needs a token with write access. Gitea's built-in `GITHUB_TOKEN` is used by +default; if the repository does not grant it write access, add a PAT as the +`RELEASE_TOKEN` secret. + ## Install via plugin repository (recommended) 1. In Jellyfin: Dashboard → Plugins → Repositories → **+** 2. Repository URL: ``` - https://git.linxweiler.xyz/pascallinxweiler/jellyfinplugin/raw/branch/main/manifest.json + https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/raw/branch/main/manifest.json ``` 3. Catalog → Authentication → **OIDC Auth** → Install → restart Jellyfin. @@ -30,12 +54,13 @@ Updates show up in the catalog automatically when a new version is added to `man ## Install manually -Copy these files from `artifacts/` into a new folder in Jellyfin's plugin directory -(e.g. `config/plugins/OidcAuth/`), then restart Jellyfin: +Extract `dist/oidc-auth_.zip` into a new folder in Jellyfin's plugin +directory (e.g. `config/plugins/OidcAuth/`), then restart Jellyfin. The zip holds: - `Jellyfin.Plugin.OidcAuth.dll` - `IdentityModel.OidcClient.dll` - `IdentityModel.dll` +- `meta.json` Requires Jellyfin 10.11.x. diff --git a/build.yaml b/build.yaml index 66eed97..68df90a 100644 --- a/build.yaml +++ b/build.yaml @@ -15,5 +15,7 @@ artifacts: - "Jellyfin.Plugin.OidcAuth.dll" - "IdentityModel.OidcClient.dll" - "IdentityModel.dll" +# Fallback changelog. The release workflow prefers the annotated tag's message. changelog: | - Initial release. + Admin role mapping now only grants admin by default. New opt-in setting + "Also revoke admin when the admin role is missing" restores the old behavior. diff --git a/manifest.json b/manifest.json index 70338ac..3814300 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ "version": "1.0.3.0", "changelog": "Admin role mapping now only grants admin by default. New opt-in setting 'Also revoke admin when the admin role is missing' restores the old revoking behavior.", "targetAbi": "10.11.0.0", - "sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinplugin/raw/branch/main/dist/oidc-auth_1.0.3.0.zip", + "sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/raw/branch/main/dist/oidc-auth_1.0.3.0.zip", "checksum": "8e1435d2d4a1587797f9c0af53f6fca2", "timestamp": "2026-07-13T13:00:00Z" }, @@ -19,7 +19,7 @@ "version": "1.0.2.0", "changelog": "Log OIDC claims and admin mapping decisions; role comparison is now case-insensitive.", "targetAbi": "10.11.0.0", - "sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinplugin/raw/branch/main/dist/oidc-auth_1.0.2.0.zip", + "sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/raw/branch/main/dist/oidc-auth_1.0.2.0.zip", "checksum": "343bcda662a3cdd1e36391222391d2d5", "timestamp": "2026-07-13T12:30:00Z" }, @@ -27,7 +27,7 @@ "version": "1.0.1.0", "changelog": "Rebuilt for Jellyfin 10.11 (net9.0).", "targetAbi": "10.11.0.0", - "sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinplugin/raw/branch/main/dist/oidc-auth_1.0.1.0.zip", + "sourceUrl": "https://git.linxweiler.xyz/pascallinxweiler/jellyfinsso/raw/branch/main/dist/oidc-auth_1.0.1.0.zip", "checksum": "8095740b87cf862ad41bc50ccbdb7443", "timestamp": "2026-07-13T12:00:00Z" } diff --git a/scripts/build_plugin.py b/scripts/build_plugin.py new file mode 100755 index 0000000..59a9fea --- /dev/null +++ b/scripts/build_plugin.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Build and package the Jellyfin plugin described by build.yaml. + +Produces the same layout as JPRM (jellyfin-plugin-repository-manager): a zip +containing the artifacts listed in build.yaml plus a meta.json, named +``_.zip``. + +Usage: + scripts/build_plugin.py --version 1.0.4.0 --changelog "..." --output dist +""" + +import argparse +import datetime +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +BUILD_YAML = REPO_ROOT / "build.yaml" +PROJECT = REPO_ROOT / "Jellyfin.Plugin.OidcAuth" / "Jellyfin.Plugin.OidcAuth.csproj" + + +def normalize_version(version): + """Pad a version to Jellyfin's four-part form (1.0.4 -> 1.0.4.0).""" + parts = version.strip().lstrip("v").split(".") + if not 1 <= len(parts) <= 4: + raise ValueError(f"'{version}' is not a 1- to 4-part version") + for part in parts: + if not part.isdigit(): + raise ValueError(f"'{version}' has a non-numeric component '{part}'") + parts += ["0"] * (4 - len(parts)) + return ".".join(parts) + + +def slugify(name): + """'OIDC Auth' -> 'oidc-auth'.""" + return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-") + + +def load_build_config(): + with BUILD_YAML.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def sync_version(version): + """Write the release version back into build.yaml and the csproj.""" + build_yaml = BUILD_YAML.read_text(encoding="utf-8") + build_yaml = re.sub( + r'^version:\s*.*$', f'version: "{version}"', build_yaml, count=1, flags=re.MULTILINE + ) + BUILD_YAML.write_text(build_yaml, encoding="utf-8") + + csproj = PROJECT.read_text(encoding="utf-8") + for tag in ("Version", "AssemblyVersion", "FileVersion"): + csproj = re.sub( + rf"<{tag}>[^<]*", f"<{tag}>{version}", csproj, count=1 + ) + PROJECT.write_text(csproj, encoding="utf-8") + + +def publish(cfg, version, configuration, publish_dir): + if publish_dir.exists(): + shutil.rmtree(publish_dir) + + command = [ + "dotnet", + "publish", + str(PROJECT), + "--configuration", + configuration, + "--framework", + cfg["framework"], + "--output", + str(publish_dir), + f"-p:Version={version}", + f"-p:AssemblyVersion={version}", + f"-p:FileVersion={version}", + # Plugin DLLs ship loose; a nuspec-style layout would confuse Jellyfin. + "-p:DebugType=none", + "-p:GenerateDocumentationFile=false", + ] + print("+ " + " ".join(command), flush=True) + subprocess.run(command, check=True, cwd=REPO_ROOT) + + +def build_metadata(cfg, version, changelog): + """The meta.json Jellyfin reads out of the plugin folder.""" + return { + "guid": cfg["guid"], + "name": cfg["name"], + "description": cfg["description"].strip(), + "overview": cfg["overview"].strip(), + "owner": cfg["owner"], + "category": cfg["category"], + "version": version, + "changelog": changelog.strip(), + "targetAbi": cfg["targetAbi"], + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .replace(tzinfo=None) + .isoformat(timespec="seconds") + + "Z", + } + + +def package(cfg, version, changelog, publish_dir, output_dir): + output_dir.mkdir(parents=True, exist_ok=True) + zip_path = output_dir / f"{slugify(cfg['name'])}_{version}.zip" + + missing = [a for a in cfg["artifacts"] if not (publish_dir / a).is_file()] + if missing: + raise SystemExit( + f"error: build.yaml lists artifacts missing from {publish_dir}: {', '.join(missing)}" + ) + + metadata = build_metadata(cfg, version, changelog) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive: + for artifact in cfg["artifacts"]: + archive.write(publish_dir / artifact, artifact) + archive.writestr("meta.json", json.dumps(metadata, indent=4) + "\n") + + return zip_path + + +def md5sum(path): + digest = hashlib.md5() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def emit_outputs(**values): + """Expose results to the workflow via $GITHUB_OUTPUT.""" + target = os.environ.get("GITHUB_OUTPUT") + if not target: + return + with open(target, "a", encoding="utf-8") as handle: + for key, value in values.items(): + handle.write(f"{key}={value}\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", help="release version; defaults to build.yaml") + parser.add_argument("--changelog", default="", help="changelog for this version") + parser.add_argument("--configuration", default="Release") + parser.add_argument("--output", default="dist", help="directory for the zip") + parser.add_argument( + "--sync-only", + action="store_true", + help="only write --version into build.yaml and the csproj, then exit", + ) + args = parser.parse_args() + + cfg = load_build_config() + version = normalize_version(args.version or cfg["version"]) + changelog = args.changelog or cfg.get("changelog", "") + + if args.sync_only: + sync_version(version) + print(f"build.yaml and csproj set to {version}") + emit_outputs(version=version) + return + + publish_dir = REPO_ROOT / "artifacts" / "publish" + publish(cfg, version, args.configuration, publish_dir) + + zip_path = package(cfg, version, changelog, publish_dir, Path(args.output).resolve()) + checksum = md5sum(zip_path) + + print(f"\npackaged {zip_path}") + print(f"version {version}") + print(f"md5 {checksum}") + + emit_outputs( + version=version, + checksum=checksum, + zip_path=zip_path, + zip_name=zip_path.name, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_manifest.py b/scripts/update_manifest.py new file mode 100755 index 0000000..cce1f19 --- /dev/null +++ b/scripts/update_manifest.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Add a version entry to the Jellyfin plugin repository manifest. + +manifest.json is what Jellyfin fetches when the repository URL is added under +Dashboard -> Plugins -> Repositories. Plugin metadata comes from build.yaml so +there is a single source of truth. + +Usage: + scripts/update_manifest.py --version 1.0.4.0 --checksum --source-url +""" + +import argparse +import datetime +import json +import re +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +BUILD_YAML = REPO_ROOT / "build.yaml" +MANIFEST = REPO_ROOT / "manifest.json" + + +def version_key(version): + return tuple(int(part) for part in re.findall(r"\d+", version)) + + +def load_manifest(): + if not MANIFEST.is_file(): + return [] + with MANIFEST.open(encoding="utf-8") as handle: + return json.load(handle) + + +def find_plugin(manifest, guid): + for plugin in manifest: + if plugin.get("guid", "").lower() == guid.lower(): + return plugin + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--checksum", required=True, help="md5 of the zip") + parser.add_argument("--source-url", required=True, help="download URL for the zip") + parser.add_argument("--changelog", default="") + parser.add_argument("--timestamp", default="", help="ISO 8601 UTC; defaults to now") + args = parser.parse_args() + + with BUILD_YAML.open(encoding="utf-8") as handle: + cfg = yaml.safe_load(handle) + + timestamp = args.timestamp or ( + datetime.datetime.now(datetime.timezone.utc) + .replace(tzinfo=None) + .isoformat(timespec="seconds") + + "Z" + ) + + manifest = load_manifest() + plugin = find_plugin(manifest, cfg["guid"]) + if plugin is None: + plugin = {"guid": cfg["guid"], "versions": []} + manifest.append(plugin) + + # Refresh the descriptive fields so build.yaml stays authoritative. + plugin["name"] = cfg["name"] + plugin["description"] = cfg["description"].strip() + plugin["overview"] = cfg["overview"].strip() + plugin["owner"] = cfg["owner"] + plugin["category"] = cfg["category"] + + entry = { + "version": args.version, + "changelog": (args.changelog or cfg.get("changelog", "")).strip(), + "targetAbi": cfg["targetAbi"], + "sourceUrl": args.source_url, + "checksum": args.checksum, + "timestamp": timestamp, + } + + versions = [v for v in plugin.get("versions", []) if v.get("version") != args.version] + versions.append(entry) + versions.sort(key=lambda v: version_key(v["version"]), reverse=True) + plugin["versions"] = versions + + # Key order matters only for readability; Jellyfin reads by name. + ordered = [ + { + key: plugin[key] + for key in ( + "guid", + "name", + "description", + "overview", + "owner", + "category", + "versions", + ) + if key in plugin + } + for plugin in manifest + ] + + MANIFEST.write_text(json.dumps(ordered, indent=4) + "\n", encoding="utf-8") + print(f"manifest.json now lists {args.version} at {args.source_url}") + + +if __name__ == "__main__": + sys.exit(main())