From c2841e8f2187c11f83f8d0824ff45cc61a8b13ce Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Sun, 25 Oct 2026 01:25:31 +0900 Subject: [PATCH] ci: Jenkins pipeline and tests for packaging/release tools Co-Authored-By: Claude Opus 5.5 --- Jenkinsfile | 55 +++++++++++++++ scripts/ci-run.sh | 15 ++++ tests/test_release_tools.py | 135 ++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 Jenkinsfile create mode 100644 scripts/ci-run.sh create mode 100644 tests/test_release_tools.py diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..2042ebe --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,55 @@ +def isMain() { + def branch = env.BRANCH_NAME ?: env.GIT_BRANCH ?: '' + return !env.CHANGE_ID && branch in ['main', 'origin/main', 'refs/heads/main'] +} + +pipeline { + agent any + + environment { + GITEA_URL = 'https://git.sejong.dev' + GITEA_REPO = 'ysj7705/mudfish-decky' + // Secret text credential: a Gitea access token allowed to write this repository's releases. + GITEA_CREDENTIALS_ID = 'gitea-release-token' + PYTHON_IMAGE = 'python:3.14-slim' + NODE_IMAGE = 'node:22-bookworm' + PNPM = 'npx -y pnpm@9.15.9' + } + + options { + timestamps() + disableConcurrentBuilds() + buildDiscarder(logRotator(numToKeepStr: '20')) + } + + stages { + stage('Test') { + steps { + sh 'sh scripts/ci-run.sh "$PYTHON_IMAGE" sh -c "PYTHONPATH=py_modules python -m unittest discover -s tests -t . -v"' + } + } + + stage('Build') { + steps { + sh 'sh scripts/ci-run.sh "$NODE_IMAGE" sh -c "$PNPM install --frozen-lockfile && $PNPM build"' + } + } + + stage('Package') { + steps { + sh 'rm -rf out && sh scripts/ci-run.sh "$PYTHON_IMAGE" python scripts/package.py out' + archiveArtifacts artifacts: 'out/*.zip', fingerprint: true + } + } + + stage('Release') { + // A release is published once per package.json version: bump the version on main to ship. + when { expression { isMain() } } + steps { + withCredentials([string(credentialsId: env.GITEA_CREDENTIALS_ID, variable: 'GITEA_TOKEN')]) { + sh 'sh scripts/ci-run.sh "$PYTHON_IMAGE" python scripts/gitea_release.py out' + } + } + } + } +} diff --git a/scripts/ci-run.sh b/scripts/ci-run.sh new file mode 100644 index 0000000..e107505 --- /dev/null +++ b/scripts/ci-run.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Run a command in a throwaway container that sees this workspace. +# The Jenkins agent talks to the host Docker daemon (DooD), so -v "$PWD" would point at a host path that +# doesn't exist; reuse the agent container's mounts when we are inside one. +# Usage: sh scripts/ci-run.sh IMAGE COMMAND [ARGS...] +set -eu +image=$1 +shift +if docker inspect "$(hostname)" >/dev/null 2>&1; then + set -- --volumes-from "$(hostname)" -w "$PWD" "$image" "$@" +else + set -- -v "$PWD":/app -w /app "$image" "$@" +fi +exec docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp \ + -e GITEA_TOKEN -e GITEA_URL -e GITEA_REPO -e GIT_COMMIT "$@" diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py new file mode 100644 index 0000000..66c6a07 --- /dev/null +++ b/tests/test_release_tools.py @@ -0,0 +1,135 @@ +import importlib.util +import json +import os +import tempfile +import unittest +import zipfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def load(name): + spec = importlib.util.spec_from_file_location(name, os.path.join(ROOT, "scripts", f"{name}.py")) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +package = load("package") +gitea_release = load("gitea_release") + + +def make_tree(root, version="0.3.1"): + files = { + "plugin.json": json.dumps({"name": "Mudfish VPN"}), + "package.json": json.dumps({"name": "mudfish-decky", "version": version}), + "main.py": "print('x')", + "README.md": "# readme", + "dist/index.js": "js", + "py_modules/mudfish_decky/__init__.py": "", + "py_modules/mudfish_decky/nodes.py": "x = 1", + "py_modules/mudfish_decky/__pycache__/nodes.cpython-314.pyc": "junk", + "tests/test_x.py": "not shipped", + "src/index.tsx": "not shipped", + } + for rel, text in files.items(): + path = os.path.join(root, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +class PackageTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.src = os.path.join(self.tmp.name, "src") + os.makedirs(self.src) + make_tree(self.src) + + def tearDown(self): + self.tmp.cleanup() + + def test_version_from_package_json(self): + self.assertEqual(package.read_version(self.src), "0.3.1") + + def test_zip_has_decky_layout_and_only_runtime_files(self): + out = package.build_zip(self.src, os.path.join(self.tmp.name, "out")) + self.assertEqual(os.path.basename(out), "mudfish-decky-0.3.1.zip") + with zipfile.ZipFile(out) as z: + names = sorted(z.namelist()) + self.assertEqual(names, [ + "mudfish-decky/README.md", + "mudfish-decky/dist/index.js", + "mudfish-decky/main.py", + "mudfish-decky/package.json", + "mudfish-decky/plugin.json", + "mudfish-decky/py_modules/mudfish_decky/__init__.py", + "mudfish-decky/py_modules/mudfish_decky/nodes.py", + ]) + + def test_missing_build_output_fails(self): + os.remove(os.path.join(self.src, "dist", "index.js")) + with self.assertRaises(SystemExit): + package.build_zip(self.src, os.path.join(self.tmp.name, "out")) + + +class FakeApi: + """Stands in for the Gitea REST API: (method, path) -> (status, json).""" + + def __init__(self, existing_tag=None): + self.existing_tag = existing_tag + self.calls = [] + + def __call__(self, method, path, body=None, headers=None): + self.calls.append((method, path, body, headers)) + if method == "GET" and path.endswith("/releases/tags/" + (self.existing_tag or "")) and self.existing_tag: + return 200, {"id": 7, "tag_name": self.existing_tag} + if method == "GET": + return 404, {"message": "not found"} + if method == "POST" and path.endswith("/releases"): + return 201, {"id": 42, "html_url": "https://git.example/r/42"} + if method == "POST" and "/assets" in path: + return 201, {"id": 1, "browser_download_url": "https://git.example/a/1"} + return 500, {} + + +class GiteaReleaseTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.zip = os.path.join(self.tmp.name, "mudfish-decky-0.3.1.zip") + with open(self.zip, "wb") as f: + f.write(b"PK-zip-bytes") + + def tearDown(self): + self.tmp.cleanup() + + def test_skips_when_release_exists(self): + api = FakeApi(existing_tag="v0.3.1") + result = gitea_release.publish(api, "ysj7705/mudfish-decky", "0.3.1", "abc123", self.zip) + self.assertIsNone(result) + self.assertEqual([c[0] for c in api.calls], ["GET"]) + + def test_creates_release_and_uploads_zip(self): + api = FakeApi() + result = gitea_release.publish(api, "ysj7705/mudfish-decky", "0.3.1", "abc123", self.zip) + self.assertEqual(result, "https://git.example/a/1") + method, path, body, _ = api.calls[1] + self.assertEqual((method, path), ("POST", "/api/v1/repos/ysj7705/mudfish-decky/releases")) + self.assertEqual(body["tag_name"], "v0.3.1") + self.assertEqual(body["target_commitish"], "abc123") + method, path, body, headers = api.calls[2] + self.assertEqual(method, "POST") + self.assertEqual(path, "/api/v1/repos/ysj7705/mudfish-decky/releases/42/assets?name=mudfish-decky-0.3.1.zip") + self.assertIn(b"PK-zip-bytes", body) + self.assertIn("multipart/form-data; boundary=", headers["Content-Type"]) + + def test_api_error_raises(self): + def forbidden(method, path, body=None, headers=None): + return (404, {}) if method == "GET" else (403, {"message": "token lacks scope"}) + + with self.assertRaises(gitea_release.ReleaseError): + gitea_release.publish(forbidden, "o/r", "0.3.1", "abc", self.zip) + + +if __name__ == "__main__": + unittest.main()