From be624982c431ad8edc5ad0ca1434046624a1b17b Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 27 Sep 2026 21:41:14 +0800 Subject: [PATCH] build(release): publish to PyPI with trusted publishing The release workflow builds and checks the sdist and wheel once, uploads them to PyPI from a `pypi` environment with the workflow's OIDC token, then creates the GitHub release with the same files. The READMEs install from PyPI, and every command argument has a help text. --- .github/workflows/release.yml | 52 +++++++++++++++++++++++++++++------ README.en.md | 2 +- README.md | 2 +- src/nnnotes/cli.py | 30 ++++++++++---------- src/nnnotes/cli_assets.py | 2 +- src/nnnotes/voices.py | 8 +++--- 6 files changed, 67 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47eee81..3866318 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,15 +5,13 @@ on: tags: ["v*"] permissions: - contents: write + contents: read jobs: - release: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.13" @@ -24,6 +22,44 @@ jobs: echo "tag $GITHUB_REF_NAME does not match the package version $version" >&2 exit 1 fi + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build twine + python -m build + python -m twine check --strict dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + + pypi: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/nnnotes/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + # trusted publishing: PyPI accepts this workflow's OIDC token, no API token is stored + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + + github-release: + needs: pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Release notes uses: orhun/git-cliff-action@v4 with: @@ -32,10 +68,10 @@ jobs: env: OUTPUT: RELEASE_NOTES.md GITHUB_REPO: ${{ github.repository }} - - name: Build sdist and wheel - run: | - python -m pip install --upgrade pip build - python -m build + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ - name: GitHub release uses: softprops/action-gh-release@v2 with: diff --git a/README.en.md b/README.en.md index b28b166..0a723ac 100644 --- a/README.en.md +++ b/README.en.md @@ -69,7 +69,7 @@ Based on all data of the Taiwan server, version 1.0.1 (zh-Hant): ## Requirements - Python 3.11+, 3.13 recommended (a `web` build reads and writes a lot of JSON, and the standard library encodes - JSON faster on 3.13); `pip install -e .` in the repository (not yet on PyPI) + JSON faster on 3.13); `pip install nnnotes` (for development `pip install -e .` in the repository) - the game's `base.apk`: APK-local bundles, the CRI keycode, boot settings. `player`, `story`, `live` and `web` read MonoBehaviours of its boot data with type trees that ship with nnnotes; supported now: game version 1.0.1 (Unity 6000.3.12f1). With an APK of another version whose classes do not match, these commands stop with an diff --git a/README.md b/README.md index 70ce15d..47165b2 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ nnnotes 是 BanG Dream! Our Notes 游戏文件的离线数据工具包:读取 ## 需要准备 -- Python 3.11+,推荐 3.13(`web` 构建读写大量 JSON,3.13 的标准库 JSON 编码更快),在仓库内 `pip install -e .`(尚未发布到 PyPI) +- Python 3.11+,推荐 3.13(`web` 构建读写大量 JSON,3.13 的标准库 JSON 编码更快),`pip install nnnotes`(开发时在仓库内 `pip install -e .`) - 游戏安装包 `base.apk`:APK 内置资源包、CRI 解码密钥、启动设置。`player`、`story`、`live`、`web` 用 nnnotes 自带的类型树读取 APK 启动数据中的 MonoBehaviour,目前支持游戏版本 1.0.1(Unity 6000.3.12f1);其他版本的 APK 若类型不符,这些命令会报错并给出类名、游戏版本和 Unity 版本 - 解码后的 masterdata 目录(可用 `master download` + `master decode` 生成):`adv`、`story`、`spot`、`live` 和 `web` 的谱面需要;`web` 的 Live2D 模型只用它取角色名(可选) - 外部工具:[vgmstream](https://vgmstream.org/)(CRI HCA 解码)、[FFmpeg](https://ffmpeg.org/)(转码,含剧情视频的 WebM 封装与 Opus 音频);`web` 另需 Node.js 20+ 与构建好的 ournotes-player diff --git a/src/nnnotes/cli.py b/src/nnnotes/cli.py index 1b65ef7..f54b9e9 100644 --- a/src/nnnotes/cli.py +++ b/src/nnnotes/cli.py @@ -546,7 +546,7 @@ def _fonts_arg(c) -> None: def _audio_args(c) -> None: """--format and --flac-level of a command that decodes cue sheets.""" - c.add_argument("--format", default="flac", choices=AUDIO_CHOICES) + c.add_argument("--format", default="flac", choices=AUDIO_CHOICES, help="audio file format (default flac)") c.add_argument("--flac-level", type=int, metavar="N", help="FLAC compression level 0-12 of --format flac (default 8; every level decodes to the same " "samples)") @@ -573,17 +573,18 @@ def build_parser() -> argparse.ArgumentParser: sub = p.add_subparsers(dest="cmd", required=True, metavar="") c = sub.add_parser("catalog", help="list addressable keys") - c.add_argument("--prefix", default="") - c.add_argument("--limit", type=int, default=200) + c.add_argument("--prefix", default="", help="only the keys that start with this prefix") + c.add_argument("--limit", type=int, default=200, help="print at most N keys (default 200; the total goes " + "to stderr)") c.set_defaults(func=cmd_catalog) c = sub.add_parser("browse", help="browse the configured regions' catalogs and bundles in a local web page") - c.add_argument("--port", type=int, default=8000) - c.add_argument("--host", default="127.0.0.1") + c.add_argument("--port", type=int, default=8000, help="port (default 8000)") + c.add_argument("--host", default="127.0.0.1", help="address to listen on (default 127.0.0.1)") c.set_defaults(func=cmd_browse) c = sub.add_parser("pull", help="fetch the bundle closure of keys into the cache") - c.add_argument("keys", nargs="+") + c.add_argument("keys", nargs="+", metavar="KEY", help="addressable keys (`catalog` lists them)") c.set_defaults(func=cmd_pull, usage=c.error) c = sub.add_parser("servers", help="the server list of the bootstrap API root: regions, their CDN and API roots") @@ -608,12 +609,12 @@ def build_parser() -> argparse.ArgumentParser: m.set_defaults(func=cmd_master_download) c = sub.add_parser("adv", help="ADV episode -> JSON") - c.add_argument("adv_id", type=int) + c.add_argument("adv_id", type=int, help="MasterAdv id of the episode") _out(c, "output .json file") c.set_defaults(func=cmd_adv, usage=c.error) c = sub.add_parser("story", help="ADV episode -> story dir (episode, models, audio, scene, UI, media, videos)") - c.add_argument("adv_id", type=int) + c.add_argument("adv_id", type=int, help="MasterAdv id of the episode") _out(c, "output directory") _audio_args(c) c.add_argument("--no-audio", action="store_true", help="do not decode the cue sheets") @@ -628,24 +629,24 @@ def build_parser() -> argparse.ArgumentParser: c.set_defaults(func=cmd_live2d, usage=c.error) c = sub.add_parser("spot", help="spot -> spot.json + Spine + room.glb + shaders") - c.add_argument("spot_id", type=int) + c.add_argument("spot_id", type=int, help="MasterSpot id") _out(c, "output directory") c.set_defaults(func=cmd_spot, usage=c.error) c = sub.add_parser("room", help="background prefab -> glb") - c.add_argument("key") + c.add_argument("key", help="addressable key of the prefab") _out(c, "output .glb file") c.set_defaults(func=cmd_room, usage=c.error) c = sub.add_parser("shader", help="dump shaders of a key's closure or of APK bundles") g = c.add_mutually_exclusive_group(required=True) - g.add_argument("--key") + g.add_argument("--key", help="addressable key: the shaders of its bundle closure") g.add_argument("--apk-bundle", nargs="+", help="substring(s) of APK bundle file names") _out(c, "output directory") c.set_defaults(func=cmd_shader, usage=c.error) c = sub.add_parser("audio", help="decode a CRI cue sheet") - c.add_argument("cue_sheet") + c.add_argument("cue_sheet", help="cue sheet name: the key Cri/Sound/ of the catalog") _out(c, "output directory") _audio_args(c) c.set_defaults(func=cmd_audio, usage=c.error) @@ -659,8 +660,9 @@ def build_parser() -> argparse.ArgumentParser: c.set_defaults(func=cmd_player) c = sub.add_parser("live", help="live (music + difficulty) -> self-contained live dir") - c.add_argument("music_id", type=int) - c.add_argument("--difficulty", default="expert", choices=DIFFICULTY_CHOICES) + c.add_argument("music_id", type=int, help="MasterLiveMusic id") + c.add_argument("--difficulty", default="expert", choices=DIFFICULTY_CHOICES, + help="chart difficulty (default expert)") _audio_args(c) _fonts_arg(c) _band_args(c) diff --git a/src/nnnotes/cli_assets.py b/src/nnnotes/cli_assets.py index bb1ffcc..d89d910 100644 --- a/src/nnnotes/cli_assets.py +++ b/src/nnnotes/cli_assets.py @@ -1487,7 +1487,7 @@ def bind(fn): c = sub.add_parser("catalogs", help="catalog versions of the store") csub = c.add_subparsers(dest="catalogs_cmd", required=True, metavar="") m = csub.add_parser("list", help="the imported catalog versions") - m.add_argument("--json", action="store_true") + m.add_argument("--json", action="store_true", help="the versions as JSON") _store_arg(m) m.set_defaults(func=bind(cmd_catalogs_list), usage=m.error) m = csub.add_parser("import", help="import a catalog file (with the APK's catalog when [paths] apk is set)") diff --git a/src/nnnotes/voices.py b/src/nnnotes/voices.py index 3c902eb..5ddbd0a 100644 --- a/src/nnnotes/voices.py +++ b/src/nnnotes/voices.py @@ -1333,7 +1333,7 @@ def base(p, text=True): p.add_argument("--source", help="a source (Talk, CharacterVoice, ..., HomeSpot, Title, Sound, Story)") p.add_argument("--episode", metavar="ADV", help="the Story voices of one episode: a MasterAdv id or an " "episode asset (without --from: read from the catalog)") - p.add_argument("--status", choices=STATUSES) + p.add_argument("--status", choices=STATUSES, help="only the rows with this status") p.add_argument("--limit", type=int, default=0, help="at most N rows (0: all)") p.add_argument("--json", action="store_true", help="the rows as JSON") @@ -1345,17 +1345,17 @@ def done(p, fn): p.add_argument("--language", choices=LANGUAGES, help="the language of the text column ([catalog] language)") done(p, cmd_list) p = vs.add_parser("search", help="the voices whose text (or cue name) contains TEXT") - p.add_argument("text") + p.add_argument("text", help="text to look for") base(p) p.add_argument("--language", choices=LANGUAGES, help="search this language only (default: all five)") done(p, cmd_search) p = vs.add_parser("summary", help="rows per status, category and character; gaps both ways") base(p, text=False) - p.add_argument("--json", action="store_true") + p.add_argument("--json", action="store_true", help="the summary as JSON") done(p, cmd_summary) p = vs.add_parser("get", help="the files of one voice: a row id (MasterTalk:561, MasterAdv::) or a " "sound id") - p.add_argument("id") + p.add_argument("id", help="row id or sound id") p.add_argument("-o", "--out", required=True, help="output directory") base(p, text=False) p.add_argument("--format", choices=("flac", "ogg", "wav"), default="flac",