ci: Jenkins pipeline and tests for packaging/release tools
ysj7705/mudfish-decky/pipeline/head There was a failure building this commit
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:
1 parent
f33bc0a49f
commit
c2841e8f21
3 files changed
+205
No files matched your search
Vendored
+55
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user