From 93baa1a7815ecaaa8c9867b6833cbc4e402e62d8 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Sun, 25 Oct 2026 01:53:31 +0900 Subject: [PATCH] ci: package the Decky zip and publish Gitea releases per version - scripts/package.py builds out/mudfish-decky-.zip with the mudfish-decky/ folder Decky expects (runtime files only). - scripts/gitea_release.py creates release v and uploads the zip, skipping versions that already have a release. - Jenkinsfile uses the organization folder's gitea-ysj7705 token. Co-Authored-By: Claude Opus 5.5 --- Jenkinsfile | 5 ++- README.md | 13 +++++- scripts/gitea_release.py | 93 ++++++++++++++++++++++++++++++++++++++++ scripts/package.py | 46 ++++++++++++++++++++ 4 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 scripts/gitea_release.py create mode 100644 scripts/package.py diff --git a/Jenkinsfile b/Jenkinsfile index 2042ebe..5c7aa17 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,8 +9,9 @@ pipeline { 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' + // The organization folder's Gitea token (Gitea plugin credentials also bind as a string). + // It must be allowed to write this repository's releases. + GITEA_CREDENTIALS_ID = 'gitea-ysj7705' PYTHON_IMAGE = 'python:3.14-slim' NODE_IMAGE = 'node:22-bookworm' PNPM = 'npx -y pnpm@9.15.9' diff --git a/README.md b/README.md index 5b9deba..24df38d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 처음 한 번만 -1. 플러그인을 `~/homebrew/plugins/mudfish-decky/`에 설치합니다. 개발 환경에서는 `scripts/deploy.sh`를 씁니다. +1. 플러그인을 설치합니다. [릴리스](https://git.sejong.dev/ysj7705/mudfish-decky/releases)에서 `mudfish-decky-<버전>.zip`의 주소를 복사한 뒤, 데키 설정 → 개발자 모드 → **Install Plugin from URL**에 붙여 넣습니다. zip 파일을 직접 받아서 **Install Plugin from ZIP**으로 설치해도 됩니다. 2. 게임모드 패널에서 **미꾸라지 설치** 버튼을 누릅니다. `mudfish.net`에서 최신 리눅스 패키지를 받아 MD5를 확인한 뒤, 설치 스크립트를 실행하지 않고 직접 풀어서 `/opt/mudfish/<버전>`에 설치합니다. 3. **휴대폰으로 로그인 (QR)** 버튼으로 로그인합니다('로그인 기억하기' 체크). @@ -39,3 +39,14 @@ - 파이썬 테스트는 스팀덱에서 실행합니다: `bash scripts/test.sh` - 빌드와 배포: `bash scripts/deploy.sh`. 덱의 `deck` 계정에 **sudo 비밀번호 없음 설정이 있어야** 동작합니다. 개발용 설정을 지운 뒤에는 쓸 수 없고, 다시 배포하려면 그 설정을 잠시 되살려야 합니다. - 설계: `docs/superpowers/specs/2026-10-24-mudfish-decky-design.md` + +## 릴리스 (Jenkins) + +`main`에 푸시하면 Jenkins(`Jenkinsfile`)가 다음 단계를 차례로 실행합니다. + +1. **Test**: `python:3.14-slim`에서 단위 테스트 +2. **Build**: `node:22`에서 pnpm 빌드 +3. **Package**: `scripts/package.py`로 `out/mudfish-decky-<버전>.zip`을 만들고 아티팩트로 보관 +4. **Release**: `scripts/gitea_release.py`로 Gitea 릴리스 `v<버전>`을 만들고 zip을 올림 + +이미 같은 버전의 릴리스가 있으면 Release 단계는 아무것도 하지 않습니다. **새 버전을 내려면 `package.json`의 `version`을 올려서 푸시**하면 됩니다. 업로드에는 Jenkins 자격증명 `gitea-ysj7705`(저장소 쓰기 권한이 있는 Gitea 토큰)를 씁니다. diff --git a/scripts/gitea_release.py b/scripts/gitea_release.py new file mode 100644 index 0000000..36cb4bc --- /dev/null +++ b/scripts/gitea_release.py @@ -0,0 +1,93 @@ +"""Publish the packaged zip as a Gitea release v, once per version. + +Usage: python scripts/gitea_release.py OUT_DIR +Env: GITEA_URL, GITEA_REPO (owner/name), GITEA_TOKEN (write access to releases), GIT_COMMIT. +""" +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from package import PLUGIN_DIR, read_version # noqa: E402 + +NOTES = """Decky Loader plugin for Mudfish Cloud VPN (Full VPN toggle, Japan node picker, QR phone login, +Mudfish install/update/remove). + +Install: Decky settings → Developer mode → Install Plugin from URL, and paste this zip's download URL. +""" + + +class ReleaseError(Exception): + pass + + +def _multipart(filename: str, data: bytes): + boundary = uuid.uuid4().hex + head = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{filename}\"\r\n" + "Content-Type: application/zip\r\n\r\n").encode() + return head + data + f"\r\n--{boundary}--\r\n".encode(), f"multipart/form-data; boundary={boundary}" + + +def publish(api, repo: str, version: str, commit: str, zip_path: str): + """Create release v with the zip attached; None if that release already exists.""" + tag = f"v{version}" + base = f"/api/v1/repos/{repo}/releases" + status, _ = api("GET", f"{base}/tags/{tag}") + if status == 200: + print(f"release {tag} already exists; nothing to publish") + return None + if status != 404: + raise ReleaseError(f"checking {tag}: HTTP {status}") + status, release = api("POST", base, {"tag_name": tag, "target_commitish": commit, + "name": f"Mudfish VPN {tag}", "body": NOTES}) + if status not in (200, 201): + raise ReleaseError(f"creating {tag}: HTTP {status} {release.get('message', '')}") + name = os.path.basename(zip_path) + with open(zip_path, "rb") as f: + body, ctype = _multipart(name, f.read()) + status, asset = api("POST", f"{base}/{release['id']}/assets?name={urllib.parse.quote(name)}", + body, {"Content-Type": ctype}) + if status not in (200, 201): + raise ReleaseError(f"uploading {name}: HTTP {status} {asset.get('message', '')}") + return asset.get("browser_download_url") + + +def http_api(base_url: str, token: str): + def call(method, path, body=None, headers=None): + headers = dict(headers or {}, Authorization=f"token {token}", Accept="application/json") + if isinstance(body, dict): + body = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(base_url.rstrip("/") + path, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + status, raw = resp.status, resp.read() + except urllib.error.HTTPError as e: + status, raw = e.code, e.read() + try: + return status, json.loads(raw) if raw else {} + except ValueError: + return status, {} + return call + + +def main(out_dir: str) -> None: + version = read_version(".") + zip_path = os.path.join(out_dir, f"{PLUGIN_DIR}-{version}.zip") + if not os.path.isfile(zip_path): + sys.exit(f"release: {zip_path} not found (run scripts/package.py first)") + api = http_api(os.environ["GITEA_URL"], os.environ["GITEA_TOKEN"]) + url = publish(api, os.environ["GITEA_REPO"], version, os.environ.get("GIT_COMMIT", "main"), zip_path) + if url: + print(f"published {url}") + + +if __name__ == "__main__": + try: + main(sys.argv[1] if len(sys.argv) > 1 else "out") + except ReleaseError as e: + sys.exit(f"release: {e}") diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..5b5037c --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,46 @@ +"""Build the installable Decky zip: /mudfish-decky-.zip with a top-level mudfish-decky/ folder. + +Usage: python scripts/package.py [OUT_DIR] (run from the repository root after `pnpm build`) +""" +import json +import os +import sys +import zipfile + +PLUGIN_DIR = "mudfish-decky" +INCLUDE = ["plugin.json", "package.json", "main.py", "README.md", "LICENSE", "dist", "py_modules"] +REQUIRED = ["plugin.json", "package.json", "main.py", os.path.join("dist", "index.js")] + + +def read_version(src: str) -> str: + with open(os.path.join(src, "package.json")) as f: + return json.load(f)["version"] + + +def _files(src: str): + for entry in INCLUDE: + path = os.path.join(src, entry) + if os.path.isfile(path): + yield entry + elif os.path.isdir(path): + for root, dirs, files in os.walk(path): + dirs[:] = sorted(d for d in dirs if d != "__pycache__") + for name in sorted(files): + if not name.endswith(".pyc"): + yield os.path.relpath(os.path.join(root, name), src) + + +def build_zip(src: str, out_dir: str) -> str: + missing = [p for p in REQUIRED if not os.path.isfile(os.path.join(src, p))] + if missing: + sys.exit(f"package: missing {', '.join(missing)} (run the build first)") + os.makedirs(out_dir, exist_ok=True) + out = os.path.join(out_dir, f"{PLUGIN_DIR}-{read_version(src)}.zip") + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: + for rel in _files(src): + z.write(os.path.join(src, rel), f"{PLUGIN_DIR}/{rel.replace(os.sep, '/')}") + return out + + +if __name__ == "__main__": + print(build_zip(".", sys.argv[1] if len(sys.argv) > 1 else "out"))