From 761446665de17f91e7bb67aa5871ae9ab9cb9c5d Mon Sep 17 00:00:00 2001 From: NoahLan <6995syu@163.com> Date: Sat, 16 May 2026 18:20:22 +0800 Subject: [PATCH] chore: v0.2.2 --- .codex/skills/iti-flask-framework/SKILL.md | 2 + README.md | 11 +- copier-template/.gitignore | 1 + copier-template/README.md.jinja | 12 +- copier-template/app.cmd.jinja | 52 ++++- copier-template/app.py.jinja | 8 +- copier-template/app.sh.jinja | 46 ++-- copier-template/config.py.jinja | 20 +- copier-template/migrations/README.md.jinja | 4 +- .../migrations/{env.py => env.py.jinja} | 6 + copier-template/pyproject.toml.jinja | 11 +- copier.yml | 8 +- docs/COPIER_TEMPLATE.md | 2 + iti/__about__.py | 2 +- scripts/iti.cmd | 13 +- scripts/iti.sh | 2 +- scripts/release.py | 146 ------------- scripts/release.sh | 202 ++++++++++++++++++ 18 files changed, 359 insertions(+), 189 deletions(-) rename copier-template/migrations/{env.py => env.py.jinja} (90%) delete mode 100644 scripts/release.py create mode 100644 scripts/release.sh diff --git a/.codex/skills/iti-flask-framework/SKILL.md b/.codex/skills/iti-flask-framework/SKILL.md index dcd384d..d7c3661 100644 --- a/.codex/skills/iti-flask-framework/SKILL.md +++ b/.codex/skills/iti-flask-framework/SKILL.md @@ -59,6 +59,8 @@ iTi-Flask 是 FastAPI 后端框架基座。 - 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。 - 已生成项目同步框架依赖用 `./app.sh framework-sync`。 - 已生成项目检查和同步模板用 `./app.sh template-check`、`./app.sh template-update`。 +- 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini`。 +- 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。 ## 命令 diff --git a/README.md b/README.md index c98e4c0..94df9cd 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ scripts\iti.cmd install ```toml dependencies = [ - "iti-flask @ git+ssh://git@your-git/iTi/iTi-Flask.git@v0.2.1", + "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v0.2.2", ] ``` @@ -107,7 +107,14 @@ cd ../my-system-app ```bash ./scripts/iti.sh release -./scripts/iti.sh release v0.2.1 +./scripts/iti.sh release v0.2.2 +``` + +Windows: + +```bat +scripts\iti.cmd release +scripts\iti.cmd release v0.2.2 ``` ## 文档 diff --git a/copier-template/.gitignore b/copier-template/.gitignore index d5fae25..1628682 100644 --- a/copier-template/.gitignore +++ b/copier-template/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .hatch/ .pytest_cache/ .mypy_cache/ +*.egg-info/ .coverage htmlcov/ *.db diff --git a/copier-template/README.md.jinja b/copier-template/README.md.jinja index ada470f..024390c 100644 --- a/copier-template/README.md.jinja +++ b/copier-template/README.md.jinja @@ -47,10 +47,18 @@ app.cmd init-system ## 开发 ```bash -./app.sh serve 8000 +./app.sh serve ./app.sh test ``` +不同环境直接在命令前设 `APP_ENV`,或把环境名作为 `serve` 的第一个参数: + +```bash +./app.sh serve +./app.sh serve test 8000 +APP_ENV=prod ./app.sh migrate +``` + ## 同步更新 同步框架包: @@ -86,4 +94,4 @@ app.cmd init-system - `migrations/versions` 必须提交。 - migration message 第一个词写作者名。 -- 生产只从 `main` 执行 `alembic upgrade head`。 +- 生产只从 `main` 执行 `alembic -c migrations/alembic.ini upgrade head`。 diff --git a/copier-template/app.cmd.jinja b/copier-template/app.cmd.jinja index 83386c7..c8a0abe 100644 --- a/copier-template/app.cmd.jinja +++ b/copier-template/app.cmd.jinja @@ -4,6 +4,7 @@ setlocal enabledelayedexpansion set "SCRIPT_DIR=%~dp0" set "ROOT_DIR=%SCRIPT_DIR%" pushd "%ROOT_DIR%" >nul +set "UV_NO_SOURCES_PACKAGE=iti-flask" set "PROJECT_VENV=%ROOT_DIR%.venv" if defined VIRTUAL_ENV ( @@ -50,8 +51,8 @@ echo install 安装开发依赖:uv sync --extra dev echo framework-sync 同步 iTi-Flask{% if include_system %} / iTi-System{% endif %} 依赖 echo template-check 检查 Copier 模板是否有更新 echo template-update 按 Copier 模板更新项目骨架 -echo test 运行测试:uv run pytest -q -echo serve [端口] 本地启动,默认 8000 +echo test 运行测试:uv run --extra dev pytest -q +echo serve [环境] [端口] 本地启动,默认 dev / 8000 echo migrate 执行 Alembic upgrade head echo migration ^<说明^> 生成 migration,说明建议以作者名开头 echo heads 查看 Alembic heads @@ -63,7 +64,9 @@ echo init-system system-sync + migrate + system-seed echo. echo 示例: echo app.cmd install -echo app.cmd serve 8000 +echo app.cmd serve +echo app.cmd serve test 8000 +echo set APP_ENV=prod ^&^& app.cmd migrate echo app.cmd migration "alice add order table" {% if include_system %}echo app.cmd init-system {% endif %}popd >nul @@ -90,17 +93,44 @@ uvx copier update --defaults --vcs-ref HEAD "%ROOT_DIR%" goto end :test -uv run pytest -q +uv run --extra dev pytest -q goto end :serve -set "PORT=%~1" +set "ENV_NAME=%APP_ENV%" +if not defined ENV_NAME set "ENV_NAME=%ITI_ENV%" +if not defined ENV_NAME set "ENV_NAME=dev" +set "PORT=8000" +set "ARG1=%~1" +if /I "%ARG1%"=="dev" ( + set "ENV_NAME=dev" + set "PORT=%~2" +) else if /I "%ARG1%"=="test" ( + set "ENV_NAME=test" + set "PORT=%~2" +) else if /I "%ARG1%"=="prod" ( + set "ENV_NAME=prod" + set "PORT=%~2" +) else if /I "%ARG1%"=="default" ( + set "ENV_NAME=dev" + set "PORT=%~2" +) else if not "%ARG1%"=="" ( + echo(%ARG1%| findstr /r "^[0-9][0-9]*$" >nul + if errorlevel 1 ( + set "ENV_NAME=%ARG1%" + set "PORT=%~2" + ) else ( + set "PORT=%ARG1%" + ) +) if "%PORT%"=="" set "PORT=8000" +set "APP_ENV=%ENV_NAME%" +set "ITI_ENV=%ENV_NAME%" uv run uvicorn app:app --reload --port "%PORT%" goto end :migrate -uv run alembic upgrade head +uv run alembic -c migrations/alembic.ini upgrade head goto end :migration @@ -109,15 +139,15 @@ if "%MESSAGE%"=="" ( echo 缺少 migration 说明。示例:app.cmd migration "alice add order table" 1>&2 exit /b 2 ) -uv run alembic revision --autogenerate -m "%MESSAGE%" +uv run alembic -c migrations/alembic.ini revision --autogenerate -m "%MESSAGE%" goto end :heads -uv run alembic heads +uv run alembic -c migrations/alembic.ini heads goto end :current -uv run alembic current +uv run alembic -c migrations/alembic.ini current goto end {% if include_system %}:system_sync @@ -131,7 +161,7 @@ goto end :init_system uv run iti-system migrations sync --target migrations/versions if errorlevel 1 goto end -uv run alembic upgrade head +uv run alembic -c migrations/alembic.ini upgrade head if errorlevel 1 goto end uv run iti-system seed system app:app goto end @@ -141,7 +171,7 @@ uv sync --extra dev if errorlevel 1 goto end {% if include_system %}uv run iti-system migrations sync --target migrations/versions if errorlevel 1 goto end -{% endif %}uv run alembic upgrade head +{% endif %}uv run alembic -c migrations/alembic.ini upgrade head if errorlevel 1 goto end {% if include_system %}uv run iti-system seed system app:app {% endif %}goto end diff --git a/copier-template/app.py.jinja b/copier-template/app.py.jinja index c4d4f0a..36df36e 100644 --- a/copier-template/app.py.jinja +++ b/copier-template/app.py.jinja @@ -1,3 +1,5 @@ +import os + from iti import create_app {% if include_system %}from iti_system import create_system_module @@ -13,4 +15,8 @@ modules = [ ] -app = create_app(config_mapping=config, modules=modules) +app = create_app( + config_name=os.getenv("APP_ENV", os.getenv("ITI_ENV", "dev")), + config_mapping=config, + modules=modules, +) diff --git a/copier-template/app.sh.jinja b/copier-template/app.sh.jinja index fe213c7..8f9dc32 100644 --- a/copier-template/app.sh.jinja +++ b/copier-template/app.sh.jinja @@ -3,6 +3,7 @@ set -eu ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) cd "$ROOT_DIR" +export UV_NO_SOURCES_PACKAGE=iti-flask PROJECT_VENV="$ROOT_DIR/.venv" case "${VIRTUAL_ENV:-}" in @@ -26,8 +27,8 @@ show_help() { framework-sync 同步 iTi-Flask{% if include_system %} / iTi-System{% endif %} 依赖 template-check 检查 Copier 模板是否有更新 template-update 按 Copier 模板更新项目骨架 - test 运行测试:uv run pytest -q - serve [端口] 本地启动,默认 8000 + test 运行测试:uv run --extra dev pytest -q + serve [环境] [端口] 本地启动,默认 dev / 8000 migrate 执行 Alembic upgrade head migration <说明> 生成 migration,说明建议以作者名开头 heads 查看 Alembic heads @@ -39,7 +40,9 @@ show_help() { 示例: ./app.sh install - ./app.sh serve 8000 + ./app.sh serve + ./app.sh serve test 8000 + APP_ENV=prod ./app.sh migrate ./app.sh migration "alice add order table" {% if include_system %} ./app.sh init-system {% endif %} @@ -66,14 +69,33 @@ case "$command" in uvx copier update --defaults --vcs-ref HEAD "$ROOT_DIR" ;; test) - uv run pytest -q + uv run --extra dev pytest -q ;; serve) - port=${1:-8000} - uv run uvicorn app:app --reload --port "$port" + env_name=${APP_ENV:-${ITI_ENV:-dev}} + port=8000 + if [ $# -gt 0 ]; then + case "$1" in + dev|test|prod|default) + env_name=$1 + port=${2:-8000} + ;; + ''|*[!0-9]*) + env_name=$1 + port=${2:-8000} + ;; + *) + port=$1 + ;; + esac + fi + if [ "$env_name" = default ]; then + env_name=dev + fi + APP_ENV="$env_name" ITI_ENV="$env_name" uv run uvicorn app:app --reload --port "$port" ;; migrate) - uv run alembic upgrade head + uv run alembic -c migrations/alembic.ini upgrade head ;; migration) message=${1:-} @@ -81,13 +103,13 @@ case "$command" in echo "缺少 migration 说明。示例:./app.sh migration \"alice add order table\"" >&2 exit 2 fi - uv run alembic revision --autogenerate -m "$message" + uv run alembic -c migrations/alembic.ini revision --autogenerate -m "$message" ;; heads) - uv run alembic heads + uv run alembic -c migrations/alembic.ini heads ;; current) - uv run alembic current + uv run alembic -c migrations/alembic.ini current ;; {% if include_system %} system-sync) uv run iti-system migrations sync --target migrations/versions @@ -97,13 +119,13 @@ case "$command" in ;; init-system) uv run iti-system migrations sync --target migrations/versions - uv run alembic upgrade head + uv run alembic -c migrations/alembic.ini upgrade head uv run iti-system seed system app:app ;; {% endif %} init) uv sync --extra dev {% if include_system %} uv run iti-system migrations sync --target migrations/versions -{% endif %} uv run alembic upgrade head +{% endif %} uv run alembic -c migrations/alembic.ini upgrade head {% if include_system %} uv run iti-system seed system app:app {% endif %} ;; *) diff --git a/copier-template/config.py.jinja b/copier-template/config.py.jinja index b11e963..cd2230c 100644 --- a/copier-template/config.py.jinja +++ b/copier-template/config.py.jinja @@ -1,6 +1,12 @@ +import os + from pathlib import Path -from iti.config import BaseConfig, DevConfig as BaseDevConfig, ProdConfig as BaseProdConfig +from iti.config import ( + BaseConfig, + DevConfig as BaseDevConfig, + ProdConfig as BaseProdConfig, +) BASE_DIR = Path(__file__).resolve().parent @@ -21,11 +27,21 @@ class TestConfig(BaseConfig): app_name="{{ project_name }}", app_env="test", testing=True, - database_url="sqlite+pysqlite:///:memory:", + database_url=os.getenv( + "DATABASE_URL", + f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:" + f"{os.getenv('MYSQL_PASSWORD', 'password')}@" + f"{os.getenv('MYSQL_HOST', '127.0.0.1')}:" + f"{os.getenv('MYSQL_PORT', '3306')}/" + f"{os.getenv('MYSQL_DATABASE', 'hsyh_mes_phase2_test')}?charset=utf8mb4", + ), base_dir=BASE_DIR, ratelimit_enabled=False, log_file_enabled=False, ) + self.app_name = "{{ project_name }}" + self.base_dir = BASE_DIR + self.log_dir = str(BASE_DIR / "runtime" / "logs") class ProdConfig(BaseProdConfig): diff --git a/copier-template/migrations/README.md.jinja b/copier-template/migrations/README.md.jinja index 04030a8..4a15d14 100644 --- a/copier-template/migrations/README.md.jinja +++ b/copier-template/migrations/README.md.jinja @@ -3,8 +3,8 @@ 本目录是业务项目唯一的 Alembic migration 流。 ```bash -uv run alembic revision --autogenerate -m "alice add example table" -uv run alembic upgrade head +uv run alembic -c migrations/alembic.ini revision --autogenerate -m "alice add example table" +uv run alembic -c migrations/alembic.ini upgrade head ``` `versions/` 下的 migration 文件必须提交到 Git。 diff --git a/copier-template/migrations/env.py b/copier-template/migrations/env.py.jinja similarity index 90% rename from copier-template/migrations/env.py rename to copier-template/migrations/env.py.jinja index 089bc4b..2a9ca9c 100644 --- a/copier-template/migrations/env.py +++ b/copier-template/migrations/env.py.jinja @@ -1,11 +1,17 @@ from __future__ import annotations import os +import sys from logging.config import fileConfig +from pathlib import Path from alembic import context from sqlalchemy import engine_from_config, pool +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + from config import config as app_config from iti.db import Base from iti.exchange import models as _exchange_models diff --git a/copier-template/pyproject.toml.jinja b/copier-template/pyproject.toml.jinja index 476e792..8670077 100644 --- a/copier-template/pyproject.toml.jinja +++ b/copier-template/pyproject.toml.jinja @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "{{ project_slug | replace('_', '-') }}" -version = "0.2.0" +version = "0.2.2" description = "{{ project_name }}" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "iti-flask @ {{ framework_git }}{% if framework_tag %}@{{ framework_tag }}{% endif %}", -{% if include_system %} "iti-system @ {{ system_git }}{% if system_tag %}@{{ system_tag }}{% endif %}", + "iti-flask @ git+{{ framework_git }}{% if framework_tag %}@{{ framework_tag }}{% endif %}", +{% if include_system %} "iti-system @ git+{{ system_git }}{% if system_tag %}@{{ system_tag }}{% endif %}", {% endif -%} ] @@ -25,3 +25,8 @@ include = ["{{ project_slug }}*"] [tool.pytest.ini_options] pythonpath = ["."] + +[tool.uv.sources] +iti-flask = { git = "{{ framework_git }}"{% if framework_tag %}, tag = "{{ framework_tag }}"{% endif %} } +{% if include_system %}iti-system = { git = "{{ system_git }}"{% if system_tag %}, tag = "{{ system_tag }}"{% endif %} } +{% endif -%} diff --git a/copier.yml b/copier.yml index c72944e..4659e47 100644 --- a/copier.yml +++ b/copier.yml @@ -21,12 +21,12 @@ project_slug: framework_git: type: str help: iTi-Flask Git 地址 - default: git+ssh://git@your-git/iTi/iTi-Flask.git + default: https://git.noahlan.cn/iti-framework/iTi-Flask.git framework_tag: type: str help: iTi-Flask Git tag - default: v0.2.1 + default: v0.2.2 include_system: type: bool @@ -36,9 +36,9 @@ include_system: system_git: type: str help: iTi-System Git 地址 - default: git+ssh://git@your-git/iTi/iTi-System.git + default: https://git.noahlan.cn/iti-framework/iTi-System.git system_tag: type: str help: iTi-System Git tag - default: v0.2.0 + default: v0.2.2 diff --git a/docs/COPIER_TEMPLATE.md b/docs/COPIER_TEMPLATE.md index faf2dc6..6517dc7 100644 --- a/docs/COPIER_TEMPLATE.md +++ b/docs/COPIER_TEMPLATE.md @@ -98,6 +98,8 @@ app.cmd init-system ./app.sh migrate ``` +`app.sh` / `app.cmd` 内部使用项目自己的 `migrations/alembic.ini`。 + 带 `iti-system` 的项目还会有: ```bash diff --git a/iti/__about__.py b/iti/__about__.py index 5eb93a5..27482d2 100644 --- a/iti/__about__.py +++ b/iti/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> # # SPDX-License-Identifier: MIT -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/scripts/iti.cmd b/scripts/iti.cmd index ffd5afb..dacc8d1 100644 --- a/scripts/iti.cmd +++ b/scripts/iti.cmd @@ -111,10 +111,19 @@ uv run alembic current goto end :release +set "SHELL_CMD=" +where sh >nul 2>nul && set "SHELL_CMD=sh" +if not defined SHELL_CMD ( + where bash >nul 2>nul && set "SHELL_CMD=bash" +) +if not defined SHELL_CMD ( + echo 找不到 sh 或 bash,无法运行 release 脚本 1>&2 + exit /b 1 +) if "%~1"=="" ( - uv run python scripts/release.py + "%SHELL_CMD%" scripts/release.sh ) else ( - uv run python scripts/release.py %* + "%SHELL_CMD%" scripts/release.sh %* ) goto end diff --git a/scripts/iti.sh b/scripts/iti.sh index c77a80f..cb671bd 100644 --- a/scripts/iti.sh +++ b/scripts/iti.sh @@ -86,7 +86,7 @@ case "$command" in uv run alembic current ;; release) - uv run python scripts/release.py "$@" + sh scripts/release.sh "$@" ;; make-app|make-system-app) target=${1:-} diff --git a/scripts/release.py b/scripts/release.py deleted file mode 100644 index 29f1173..0000000 --- a/scripts/release.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import re -import subprocess -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -ABOUT_FILE = ROOT / "iti" / "__about__.py" -COPIER_FILE = ROOT / "copier.yml" -README_FILE = ROOT / "README.md" - -VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") -ABOUT_VERSION_RE = re.compile(r'(__version__\s*=\s*")([^"]+)(")') -README_DEP_PREFIX = r"(iti-flask @ git\+ssh://git@your-git/iTi/iTi-Flask\.git@)" - - -def run(cmd: list[str], *, capture_output: bool = False) -> subprocess.CompletedProcess[str]: - return subprocess.run( - cmd, - cwd=ROOT, - text=True, - capture_output=capture_output, - check=True, - ) - - -def normalize_version(value: str) -> str: - version = value[1:] if value.startswith("v") else value - if not VERSION_RE.fullmatch(version): - raise SystemExit(f"invalid version: {value}") - return version - - -def version_tuple(version: str) -> tuple[int, int, int]: - major, minor, patch = version.split(".") - return int(major), int(minor), int(patch) - - -def bump_patch(version: str) -> str: - major, minor, patch = version_tuple(version) - return f"{major}.{minor}.{patch + 1}" - - -def read_current_version() -> str: - text = ABOUT_FILE.read_text(encoding="utf-8") - match = ABOUT_VERSION_RE.search(text) - if match is None: - raise SystemExit(f"version not found in {ABOUT_FILE}") - return match.group(2) - - -def write_about_version(version: str) -> None: - text = ABOUT_FILE.read_text(encoding="utf-8") - new_text, count = ABOUT_VERSION_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) - if count != 1: - raise SystemExit(f"version line not updated in {ABOUT_FILE}") - ABOUT_FILE.write_text(new_text, encoding="utf-8") - - -def write_copier_tag(current_tag: str, new_tag: str) -> None: - text = COPIER_FILE.read_text(encoding="utf-8") - pattern = re.compile(r"(framework_tag:\n(?: .*\n)*? default: )" + re.escape(current_tag)) - new_text, count = pattern.subn(rf"\g<1>{new_tag}", text, count=1) - if count != 1: - raise SystemExit(f"framework_tag default not updated in {COPIER_FILE}") - COPIER_FILE.write_text(new_text, encoding="utf-8") - - -def write_readme_tag(current_tag: str, new_tag: str) -> None: - text = README_FILE.read_text(encoding="utf-8") - pattern = re.compile(README_DEP_PREFIX + re.escape(current_tag)) - new_text, count = pattern.subn(rf"\g<1>{new_tag}", text, count=1) - if count != 1: - raise SystemExit(f"framework dependency example not updated in {README_FILE}") - README_FILE.write_text(new_text, encoding="utf-8") - - -def ensure_clean_tree() -> None: - result = run(["git", "status", "--porcelain"], capture_output=True) - if result.stdout.strip(): - raise SystemExit("working tree is not clean") - - -def ensure_branch() -> str: - result = run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], capture_output=True) - branch = result.stdout.strip() - if not branch: - raise SystemExit("release requires a branch checkout") - return branch - - -def ensure_tag_absent(tag: str) -> None: - result = subprocess.run( - ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"], - cwd=ROOT, - text=True, - capture_output=True, - ) - if result.returncode == 0: - raise SystemExit(f"tag already exists: {tag}") - - -def main() -> int: - parser = argparse.ArgumentParser(description="Release the iTi-Flask framework") - parser.add_argument( - "version", - nargs="?", - help="target version, for example 0.2.1 or v0.2.1; defaults to patch bump", - ) - args = parser.parse_args() - - current_version = read_current_version() - target_version = normalize_version(args.version) if args.version else bump_patch(current_version) - target_tag = f"v{target_version}" - current_tag = f"v{current_version}" - - if target_version == current_version: - raise SystemExit(f"version already set to {target_version}") - if version_tuple(target_version) <= version_tuple(current_version): - raise SystemExit(f"target version must be newer than {current_version}") - - ensure_clean_tree() - ensure_branch() - ensure_tag_absent(target_tag) - run(["uv", "run", "pytest", "-q"]) - - write_about_version(target_version) - write_copier_tag(current_tag, target_tag) - write_readme_tag(current_tag, target_tag) - - run(["git", "add", "iti/__about__.py", "copier.yml", "README.md"]) - run(["git", "commit", "-m", f"chore: release {target_tag}"]) - run(["git", "tag", "-a", target_tag, "-m", f"release {target_tag}"]) - - branch = ensure_branch() - run(["git", "push", "origin", branch, target_tag]) - - print(f"released {target_tag}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..bd0ddee --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT_DIR" + +ABOUT_FILE="$ROOT_DIR/iti/__about__.py" +COPIER_FILE="$ROOT_DIR/copier.yml" +README_FILE="$ROOT_DIR/README.md" + +die() { + printf '%s\n' "$*" >&2 + exit 1 +} + +parse_version() { + raw=$1 + version=${raw#v} + old_ifs=$IFS + IFS=. + set -- $version + IFS=$old_ifs + + if [ "$#" -ne 3 ]; then + die "invalid version: $raw" + fi + + for part in "$@"; do + case $part in + ''|*[!0-9]*) + die "invalid version: $raw" + ;; + esac + done + + printf '%s %s %s\n' "$1" "$2" "$3" +} + +normalize_version() { + set -- $(parse_version "$1") + printf '%s.%s.%s\n' "$1" "$2" "$3" +} + +bump_patch() { + set -- $(parse_version "$1") + printf '%s.%s.%s\n' "$1" "$2" "$(( $3 + 1 ))" +} + +version_is_newer() { + current_parts=$(parse_version "$1") + set -- $current_parts + current_major=$1 + current_minor=$2 + current_patch=$3 + + target_parts=$(parse_version "$2") + set -- $target_parts + target_major=$1 + target_minor=$2 + target_patch=$3 + + if [ "$target_major" -gt "$current_major" ]; then + return 0 + fi + if [ "$target_major" -lt "$current_major" ]; then + return 1 + fi + if [ "$target_minor" -gt "$current_minor" ]; then + return 0 + fi + if [ "$target_minor" -lt "$current_minor" ]; then + return 1 + fi + [ "$target_patch" -gt "$current_patch" ] +} + +read_current_version() { + current_version=$(sed -n 's/^__version__[[:space:]]*=[[:space:]]*"\([^"]*\)".*$/\1/p' "$ABOUT_FILE" | sed -n '1p') + [ -n "$current_version" ] || die "version not found in $ABOUT_FILE" + printf '%s\n' "$current_version" +} + +replace_first_exact_line() { + file=$1 + old_line=$2 + new_line=$3 + tmp_file=$(mktemp) + + if awk -v old="$old_line" -v new="$new_line" ' + BEGIN { done = 0 } + { + if (!done && $0 == old) { + print new + done = 1 + } else { + print + } + } + END { if (!done) exit 1 } + ' "$file" >"$tmp_file"; then + mv "$tmp_file" "$file" + else + rm -f "$tmp_file" + die "line not updated in $file" + fi +} + +write_about_version() { + current_version=$1 + target_version=$2 + replace_first_exact_line "$ABOUT_FILE" "__version__ = \"$current_version\"" "__version__ = \"$target_version\"" +} + +write_copier_tag() { + current_version=$1 + target_version=$2 + tmp_file=$(mktemp) + + if awk -v current="$current_version" -v target="$target_version" ' + BEGIN { in_framework = 0; done = 0 } + /^framework_tag:[[:space:]]*$/ { in_framework = 1; print; next } + in_framework && /^[^[:space:]]/ { in_framework = 0 } + in_framework && !done && $0 == " default: v" current { + print " default: v" target + done = 1 + next + } + { print } + END { if (!done) exit 1 } + ' "$COPIER_FILE" >"$tmp_file"; then + mv "$tmp_file" "$COPIER_FILE" + else + rm -f "$tmp_file" + die "framework_tag default not updated in $COPIER_FILE" + fi +} + +write_readme_tag() { + current_version=$1 + target_version=$2 + old_line=' "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v'"$current_version"'",' + new_line=' "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v'"$target_version"'",' + replace_first_exact_line "$README_FILE" "$old_line" "$new_line" +} + +ensure_clean_tree() { + if [ -n "$(git status --porcelain)" ]; then + die "working tree is not clean" + fi +} + +ensure_branch() { + if branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then + [ -n "$branch" ] || die "release requires a branch checkout" + printf '%s\n' "$branch" + else + die "release requires a branch checkout" + fi +} + +ensure_tag_absent() { + tag=$1 + if git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null; then + die "tag already exists: $tag" + fi +} + +main() { + version_arg=${1:-} + current_version=$(read_current_version) + if [ -n "$version_arg" ]; then + target_version=$(normalize_version "$version_arg") + else + target_version=$(bump_patch "$current_version") + fi + target_tag="v$target_version" + + if [ "$target_version" = "$current_version" ]; then + die "version already set to $target_version" + fi + if ! version_is_newer "$current_version" "$target_version"; then + die "target version must be newer than $current_version" + fi + + ensure_clean_tree + branch=$(ensure_branch) + ensure_tag_absent "$target_tag" + uv run pytest -q + + write_about_version "$current_version" "$target_version" + write_copier_tag "$current_version" "$target_version" + write_readme_tag "$current_version" "$target_version" + + git add iti/__about__.py copier.yml README.md + git commit -m "chore: release $target_tag" + git tag -a "$target_tag" -m "release $target_tag" + git push origin "$branch" "$target_tag" + + printf 'released %s\n' "$target_tag" +} + +main "$@"