ci: Jenkins pipeline and tests for packaging/release tools
ysj7705/mudfish-decky/pipeline/head There was a failure building this commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-25 01:25:31 +09:00
1 parent f33bc0a49f
commit c2841e8f21
3 files changed
+205

No files matched your search

Vendored
+55
View File
@@ -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'
}
}
}
}
}
+15
View File
@@ -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 "$@"
+135
View File
@@ -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()