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