release: add portable zipapp and installer

This commit is contained in:
roy1994 2026-08-23 05:25:05 +08:00
parent e053d31a2f
commit a107008467
4 changed files with 270 additions and 1 deletions

5
.gitignore vendored
View File

@ -3,4 +3,7 @@ __pycache__/
.ruff_cache/
*.egg-info/
build/
dist/
dist/*
!dist/
!dist/ictlogin.pyz
!dist/ictlogin.pyz.sha256

View File

@ -14,6 +14,31 @@ python3 -m pip install --user --no-deps --no-build-isolation .
如果不想安装,也可以直接运行 `python3 run_ictlogin.py ...`。
## 单文件发布
构建可直接复制到其他 Linux 服务器的 zipapp:
```bash
python3 scripts/build_release.py
./dist/ictlogin.pyz --version
(cd dist && sha256sum -c ictlogin.pyz.sha256)
```
在包含 `install.sh` 和 `dist/ictlogin.pyz` 的目录中执行:
```bash
./install.sh
```
安装脚本默认写入 `~/.local/bin/ictlogin`,会自动校验同目录的 `.sha256` 文件。也可以从指定文件或 URL 安装:
```bash
./install.sh --source /path/to/ictlogin.pyz --prefix "$HOME/.local/bin"
ICTLOGIN_PYZ_URL="https://example.invalid/ictlogin.pyz" ./install.sh
```
当前 Gitea 仓库是私有仓库;在没有仓库访问凭据的服务器上,请先通过 `scp` 复制 `install.sh`、`dist/ictlogin.pyz` 和 `dist/ictlogin.pyz.sha256`,再运行安装脚本。
## 使用
先检查当前 IP:

164
install.sh Executable file
View File

@ -0,0 +1,164 @@
#!/bin/sh
# Install the portable ICT login zipapp for the current user.
set -eu
usage() {
cat <<'EOF'
Usage: install.sh [options]
Options:
-s, --source PATH|URL install this .pyz file
-p, --prefix DIR install directory (default: $HOME/.local/bin)
--sha256 HEX expected SHA-256 of the .pyz
-h, --help show this help
Environment overrides:
ICTLOGIN_PYZ local .pyz path or URL
ICTLOGIN_PYZ_URL download URL when no local artifact is present
ICTLOGIN_PYZ_SHA256 expected SHA-256
ICTLOGIN_PREFIX install directory
ICTLOGIN_PYTHON Python 3 executable
EOF
}
SOURCE="${ICTLOGIN_PYZ:-}"
URL="${ICTLOGIN_PYZ_URL:-}"
EXPECTED_SHA256="${ICTLOGIN_PYZ_SHA256:-}"
PREFIX="${ICTLOGIN_PREFIX:-${HOME}/.local/bin}"
while [ "$#" -gt 0 ]; do
case "$1" in
-s|--source)
[ "$#" -ge 2 ] || { echo "--source requires a value" >&2; exit 2; }
SOURCE=$2
shift 2
;;
-p|--prefix)
[ "$#" -ge 2 ] || { echo "--prefix requires a value" >&2; exit 2; }
PREFIX=$2
shift 2
;;
--sha256)
[ "$#" -ge 2 ] || { echo "--sha256 requires a value" >&2; exit 2; }
EXPECTED_SHA256=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
PYTHON="${ICTLOGIN_PYTHON:-}"
if [ -z "$PYTHON" ]; then
PYTHON=$(command -v python3 2>/dev/null || true)
fi
if [ -z "$PYTHON" ]; then
PYTHON=$(command -v python 2>/dev/null || true)
fi
if [ -z "$PYTHON" ]; then
echo "Python 3 is required" >&2
exit 1
fi
if ! "$PYTHON" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then
echo "Python 3.8 or newer is required: $PYTHON" >&2
exit 1
fi
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
if [ -z "$SOURCE" ] && [ -z "$URL" ]; then
if [ -f "$SCRIPT_DIR/dist/ictlogin.pyz" ]; then
SOURCE=$SCRIPT_DIR/dist/ictlogin.pyz
elif [ -f "$SCRIPT_DIR/ictlogin.pyz" ]; then
SOURCE=$SCRIPT_DIR/ictlogin.pyz
else
URL="https://gitx.roy1994.top/mancitrus/ictlogin/raw/branch/main/dist/ictlogin.pyz"
fi
fi
SOURCE_PATH=""
case "$SOURCE" in
http://*|https://*) URL=$SOURCE; SOURCE="" ;;
"") ;;
*)
SOURCE_PATH=$SOURCE
[ -f "$SOURCE_PATH" ] || { echo "file not found: $SOURCE_PATH" >&2; exit 1; }
;;
esac
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/ictlogin.pyz.XXXXXX")
cleanup() {
rm -f "$TMP_FILE"
}
trap cleanup EXIT HUP INT TERM
if [ -n "$URL" ]; then
if command -v curl >/dev/null 2>&1; then
curl --fail --location --silent --show-error -o "$TMP_FILE" "$URL"
elif command -v wget >/dev/null 2>&1; then
wget --quiet --output-document="$TMP_FILE" "$URL"
else
echo "curl or wget is required to download $URL" >&2
exit 1
fi
else
cp "$SOURCE_PATH" "$TMP_FILE"
fi
if [ -z "$EXPECTED_SHA256" ] && [ -n "$SOURCE_PATH" ] && [ -f "${SOURCE_PATH}.sha256" ]; then
EXPECTED_SHA256=$(awk 'NF { print $1; exit }' "${SOURCE_PATH}.sha256")
fi
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
"$PYTHON" - "$1" <<'PY'
import hashlib
import sys
digest = hashlib.sha256()
with open(sys.argv[1], "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
print(digest.hexdigest())
PY
fi
}
if [ -n "$EXPECTED_SHA256" ]; then
ACTUAL_SHA256=$(sha256_file "$TMP_FILE")
if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then
echo "SHA-256 verification failed" >&2
echo "expected: $EXPECTED_SHA256" >&2
echo "actual: $ACTUAL_SHA256" >&2
exit 1
fi
fi
"$PYTHON" "$TMP_FILE" --version >/dev/null
mkdir -p "$PREFIX"
TARGET="$PREFIX/ictlogin"
if command -v install >/dev/null 2>&1; then
install -m 0755 "$TMP_FILE" "$TARGET"
else
cp "$TMP_FILE" "$TARGET"
chmod 0755 "$TARGET"
fi
echo "installed $TARGET"
case ":${PATH:-}:" in
*":$PREFIX:"*) ;;
*) echo "add to PATH: export PATH=\"$PREFIX:\$PATH\"" ;;
esac
"$TARGET" --version

77
scripts/build_release.py Executable file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Build the dependency-free ICT login zipapp and its checksum."""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import tempfile
from pathlib import Path
from zipapp import create_archive
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT / "dist" / "ictlogin.pyz"
def _copy_source(source: Path, target: Path) -> None:
target.mkdir(parents=True, exist_ok=True)
shutil.copytree(
source,
target / "ictlogin",
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.egg-info"),
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def build(output: Path, checksum_path: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
staging_root = Path(tempfile.mkdtemp(prefix="ictlogin-build-"))
temporary_output = output.with_name(output.name + ".tmp")
try:
_copy_source(ROOT / "src" / "ictlogin", staging_root)
create_archive(
staging_root,
temporary_output,
interpreter="/usr/bin/env python3",
main="ictlogin.cli:main",
compressed=True,
)
os.chmod(temporary_output, 0o755)
os.replace(temporary_output, output)
digest = _sha256(output)
checksum_path.parent.mkdir(parents=True, exist_ok=True)
checksum_path.write_text(f"{digest} {output.name}\n", encoding="ascii")
os.chmod(checksum_path, 0o644)
finally:
if temporary_output.exists():
temporary_output.unlink()
shutil.rmtree(staging_root, ignore_errors=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--output", type=Path, default=DEFAULT_OUTPUT, help="output .pyz path"
)
parser.add_argument(
"--checksum", type=Path, help="checksum path (defaults to OUTPUT.sha256)"
)
args = parser.parse_args()
checksum_path = args.checksum or Path(str(args.output) + ".sha256")
build(args.output.resolve(), checksum_path.resolve())
print(f"built {args.output}")
print(f"sha256 {checksum_path}")
if __name__ == "__main__":
main()